feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat
Merges workspace.html + ralph.html into a single unified project hub with: - Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict) - 9-state project badge system (running-ralph/manual, planning, approved, pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix - SSE realtime + polling fallback, version-based optimistic concurrency (If-Match) - Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume) - Propose modal (Variant B: inline Plan-with-Echo checkbox) - 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar) - Inter font self-hosted + shared tokens.css design system + DESIGN.md - src/jsonlock.py (flock helper, sidecar .lock for stable inode) - src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh) - 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28 - No emoji anywhere (enforced by test_dashboard_no_emoji.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
54
dashboard/handlers/_validators.py
Normal file
54
dashboard/handlers/_validators.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Shared validation helpers for dashboard handlers."""
|
||||
import json
|
||||
import re
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
_SLUG_RE = re.compile(r'^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$')
|
||||
|
||||
|
||||
def validate_slug(slug: str) -> str | None:
|
||||
"""Returns error message or None if valid."""
|
||||
if not slug:
|
||||
return "slug required"
|
||||
if not _SLUG_RE.match(slug):
|
||||
return "slug must be 3-40 chars, lowercase alphanumeric + hyphens/underscores"
|
||||
return None
|
||||
|
||||
|
||||
def validate_description(desc: str) -> str | None:
|
||||
"""Returns error message or None if valid. Min 10 chars, max 500."""
|
||||
if not desc or len(desc.strip()) < 10:
|
||||
return "description must be at least 10 characters"
|
||||
if len(desc) > 500:
|
||||
return "description must be at most 500 characters"
|
||||
return None
|
||||
|
||||
|
||||
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict | None:
|
||||
"""Parse JSON body from request. Returns None on failure (sends 400)."""
|
||||
try:
|
||||
length = int(handler.headers.get('Content-Length', '0') or '0')
|
||||
except (TypeError, ValueError):
|
||||
length = 0
|
||||
|
||||
def _send_error(msg: str) -> None:
|
||||
sender = getattr(handler, 'send_json', None)
|
||||
if callable(sender):
|
||||
sender({'error': msg}, 400)
|
||||
return
|
||||
body = json.dumps({'error': msg}).encode()
|
||||
handler.send_response(400)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
if length <= 0:
|
||||
_send_error('empty body')
|
||||
return None
|
||||
try:
|
||||
raw = handler.rfile.read(length)
|
||||
return json.loads(raw.decode('utf-8'))
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
_send_error('invalid JSON body')
|
||||
return None
|
||||
174
dashboard/handlers/auth.py
Normal file
174
dashboard/handlers/auth.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Cookie-based authentication for the unified dashboard.
|
||||
|
||||
This mixin provides:
|
||||
|
||||
- POST /api/auth/login — exchanges a token (form body) for a cookie.
|
||||
- POST /api/auth/logout — clears the cookie.
|
||||
- _check_dashboard_cookie — used by the global POST middleware (and the
|
||||
SSE GET endpoint) to gate access.
|
||||
|
||||
`DASHBOARD_TOKEN` is read once from `dashboard/.env` (loaded into
|
||||
`os.environ` by `dashboard/constants.py` at import time). When the token is
|
||||
not configured we generate a random one at startup, stash it in-process,
|
||||
and warn loudly to stderr — this means the dashboard is reachable from
|
||||
localhost only with a freshly-printed token (printed once at boot).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 30 days
|
||||
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
||||
_COOKIE_NAME = "dashboard"
|
||||
_COOKIE_PATH = "/echo/"
|
||||
|
||||
# Module-level cache for the resolved token. Set lazily on first call so
|
||||
# importing this module doesn't have a side effect at process boot.
|
||||
_DASHBOARD_TOKEN: str | None = None
|
||||
|
||||
|
||||
def _get_dashboard_token() -> str:
|
||||
"""Return the dashboard token (cached). Generates a random one if absent.
|
||||
|
||||
`dashboard/constants.py` already loads `dashboard/.env` into os.environ at
|
||||
import time, so by the time this is called the value (if present) is in
|
||||
`os.environ['DASHBOARD_TOKEN']`. If missing, we mint a 32-byte URL-safe
|
||||
token and warn — operators must read it from the log to log in.
|
||||
"""
|
||||
global _DASHBOARD_TOKEN
|
||||
if _DASHBOARD_TOKEN is not None:
|
||||
return _DASHBOARD_TOKEN
|
||||
token = os.environ.get("DASHBOARD_TOKEN", "").strip()
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
msg = (
|
||||
"[auth] DASHBOARD_TOKEN not set in dashboard/.env — generated a "
|
||||
f"random token for this process: {token}\n"
|
||||
" Add `DASHBOARD_TOKEN=<value>` to dashboard/.env to make it "
|
||||
"stable across restarts.\n"
|
||||
)
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
log.warning("DASHBOARD_TOKEN not configured — using ephemeral token")
|
||||
_DASHBOARD_TOKEN = token
|
||||
return token
|
||||
|
||||
|
||||
def _parse_cookie_header(raw: str) -> dict[str, str]:
|
||||
"""Tiny RFC 6265 cookie-pair parser. Last-write-wins on duplicates."""
|
||||
out: dict[str, str] = {}
|
||||
if not raw:
|
||||
return out
|
||||
for chunk in raw.split(";"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk or "=" not in chunk:
|
||||
continue
|
||||
k, v = chunk.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
class AuthHandlers:
|
||||
"""Mixin: /api/auth/login, /api/auth/logout, plus _check_dashboard_cookie."""
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
def _check_dashboard_cookie(self) -> bool:
|
||||
"""Return True if the request carries a valid `dashboard` cookie."""
|
||||
raw = self.headers.get("Cookie", "") or ""
|
||||
cookies = _parse_cookie_header(raw)
|
||||
provided = cookies.get(_COOKIE_NAME, "")
|
||||
if not provided:
|
||||
return False
|
||||
expected = _get_dashboard_token()
|
||||
# Constant-time compare — token guess attacks aren't realistic here
|
||||
# (cookie path is /echo/, HttpOnly), but cheap defense in depth.
|
||||
return secrets.compare_digest(provided, expected)
|
||||
|
||||
def _read_form_body(self) -> dict[str, str]:
|
||||
"""Parse `application/x-www-form-urlencoded` POST body."""
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
except (TypeError, ValueError):
|
||||
length = 0
|
||||
if length <= 0:
|
||||
return {}
|
||||
try:
|
||||
raw = self.rfile.read(length).decode("utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return {}
|
||||
parsed = parse_qs(raw, keep_blank_values=True)
|
||||
# Flatten — single-value form fields only
|
||||
return {k: v[0] for k, v in parsed.items() if v}
|
||||
|
||||
# ── POST /api/auth/login ───────────────────────────────────────────
|
||||
def handle_login(self):
|
||||
"""Validate token from form body; on success, set cookie + 302 to workspace.
|
||||
|
||||
On failure, return 401 JSON. The cookie is set with HttpOnly +
|
||||
SameSite=Strict; Path=/echo/ so it scopes to the dashboard reverse
|
||||
proxy mount.
|
||||
"""
|
||||
# Accept JSON body too (login.html might POST JSON in Lane B2)
|
||||
ctype = (self.headers.get("Content-Type", "") or "").lower()
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
raw = self.rfile.read(length).decode("utf-8") if length > 0 else ""
|
||||
form = json.loads(raw) if raw else {}
|
||||
if not isinstance(form, dict):
|
||||
form = {}
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
form = {}
|
||||
else:
|
||||
form = self._read_form_body()
|
||||
|
||||
provided = (form.get("token") or "").strip()
|
||||
expected = _get_dashboard_token()
|
||||
if not provided or not secrets.compare_digest(provided, expected):
|
||||
body = json.dumps({"error": "Invalid token"}).encode("utf-8")
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
|
||||
cookie = (
|
||||
f"{_COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; "
|
||||
f"Path={_COOKIE_PATH}; Max-Age={_COOKIE_MAX_AGE}"
|
||||
)
|
||||
self.send_response(302)
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Location", "/echo/workspace.html")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
# ── POST /api/auth/logout ──────────────────────────────────────────
|
||||
def handle_logout(self):
|
||||
"""Clear the dashboard cookie. Returns 200 JSON `{"ok": true}`."""
|
||||
cookie = (
|
||||
f"{_COOKIE_NAME}=; HttpOnly; SameSite=Strict; "
|
||||
f"Path={_COOKIE_PATH}; Max-Age=0"
|
||||
)
|
||||
body = json.dumps({"ok": True}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
1014
dashboard/handlers/projects.py
Normal file
1014
dashboard/handlers/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -34,6 +33,8 @@ from pathlib import Path
|
||||
|
||||
import constants
|
||||
|
||||
from handlers._validators import _SLUG_RE, validate_slug
|
||||
|
||||
# Best-effort import of pure functions for /api/ralph/usage (instrumentation MVP).
|
||||
# Helper lives at <repo>/tools/ralph_usage.py — sibling of `dashboard/`.
|
||||
_TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
@@ -45,10 +46,6 @@ except ImportError: # pragma: no cover — diagnostic only
|
||||
ralph_usage = None # type: ignore
|
||||
|
||||
|
||||
# Slug strict: alphanum + dash + underscore, max 64 chars. Reject path traversal explicit.
|
||||
_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
# Path Ralph per proiect (mereu în scripts/ralph/)
|
||||
def _ralph_dir(project_dir: Path) -> Path:
|
||||
return project_dir / "scripts" / "ralph"
|
||||
@@ -65,17 +62,11 @@ class RalphHandlers:
|
||||
def _ralph_validate_slug(self, slug: str):
|
||||
"""Validează slug-ul + returnează project_dir sau None.
|
||||
|
||||
Strict: alphanum + dash + underscore, ≤64 chars. Path traversal sequences
|
||||
(`..`, `/`, `\\`) sau caractere ne-alfanumerice sunt respinse înainte de
|
||||
orice atingere a filesystem-ului.
|
||||
Delegates the slug-shape check to the shared `validate_slug` helper
|
||||
in `dashboard/handlers/_validators.py`; only filesystem checks remain
|
||||
here (existence + path-confinement under WORKSPACE_DIR).
|
||||
"""
|
||||
if not slug:
|
||||
return None
|
||||
# Defense-in-depth: explicit path-traversal/separator reject (regex îl
|
||||
# acoperă, dar îl ţinem explicit ca safety net dacă regex-ul se relaxează).
|
||||
if ".." in slug or "/" in slug or "\\" in slug:
|
||||
return None
|
||||
if not _SLUG_RE.match(slug):
|
||||
if validate_slug(slug) is not None:
|
||||
return None
|
||||
project_dir = constants.WORKSPACE_DIR / slug
|
||||
try:
|
||||
|
||||
@@ -11,13 +11,15 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
from handlers._validators import validate_slug
|
||||
|
||||
|
||||
class WorkspaceHandlers:
|
||||
"""Mixin for /api/workspace and /api/workspace/*."""
|
||||
|
||||
def _validate_project(self, name):
|
||||
"""Validate project name and return its path, or None."""
|
||||
if not name or '/' in name or '..' in name:
|
||||
if validate_slug(name) is not None:
|
||||
return None
|
||||
project_dir = constants.WORKSPACE_DIR / name
|
||||
if not project_dir.exists() or not project_dir.is_dir():
|
||||
|
||||
Reference in New Issue
Block a user