Default per-user main.py; invite-only by default

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 01:45:20 +12:00
parent 6fc651ad72
commit 687a8347f8
7 changed files with 97 additions and 14 deletions

View File

@@ -8,8 +8,8 @@
# --- User accounts (SQLite) --- # --- User accounts (SQLite) ---
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*) # AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register # AUTH_REGISTER_OPEN=false # allow POST /api/auth/register
# AUTH_INVITE_ONLY=false # require invite token for registration # AUTH_INVITE_ONLY=true # default: require invite token; set false for open signup
# AUTH_DATABASE_PATH=./data/editor.db # AUTH_DATABASE_PATH=./data/editor.db
# AUTH_SESSION_DAYS=14 # AUTH_SESSION_DAYS=14
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users # BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users

View File

@@ -73,13 +73,13 @@ Notes:
- `data/` is mounted to `/app/data` for the SQLite auth DB. - `data/` is mounted to `/app/data` for the SQLite auth DB.
- In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`. - In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`.
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts. **User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` with an invite link (unless you opt into open signup) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
Email invite signup: Email invite signup:
- By default **`AUTH_INVITE_ONLY=true`**: registrations need a valid invite token. Set **`AUTH_INVITE_ONLY=false`** to allow open signup whenever **`AUTH_REGISTER_OPEN=true`**.
- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`. - Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`.
- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically. - Response includes `invite_url`; if SMTP is configured the invite email is sent automatically.
- Set `AUTH_INVITE_ONLY=true` to require invite tokens for all registrations.
- Registration page accepts invite links like `/register?invite=<token>`. - Registration page accepts invite links like `/register?invite=<token>`.
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything. When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything.

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from pathlib import Path from pathlib import Path
from fastapi import Cookie, Depends, Header, HTTPException, Query from fastapi import Cookie, Depends, Header, HTTPException, Query
@@ -11,6 +10,7 @@ from editor_app.db.session import get_db
from editor_app.db.models import User from editor_app.db.models import User
from editor_app import config from editor_app import config
from editor_app.services import accounts from editor_app.services import accounts
from editor_app.services import user_workspace
def api_key_valid(authorization: str | None) -> bool: def api_key_valid(authorization: str | None) -> bool:
@@ -66,13 +66,8 @@ async def require_superuser(
return user return user
def _safe_workspace_leaf(user: User) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", user.username).strip("-").lower() or "user"
return f"{base}-{user.id}"
def _seed_user_workspace(user_root: Path) -> None: def _seed_user_workspace(user_root: Path) -> None:
(user_root / "code").mkdir(parents=True, exist_ok=True) user_workspace.ensure_default_code_main(user_root)
async def get_workspace_root( async def get_workspace_root(
@@ -91,6 +86,6 @@ async def get_workspace_root(
if lookup is None: if lookup is None:
raise HTTPException(status_code=404, detail="Workspace user not found") raise HTTPException(status_code=404, detail="Workspace user not found")
target_user = lookup target_user = lookup
user_root = root / "users" / _safe_workspace_leaf(target_user) user_root = user_workspace.user_workspace_root(target_user.id, target_user.username, workspace_root=root)
_seed_user_workspace(user_root) _seed_user_workspace(user_root)
return user_root return user_root

View File

@@ -12,6 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from editor_app.db.models import AuthSession, InviteToken, User from editor_app.db.models import AuthSession, InviteToken, User
from editor_app.services import user_workspace
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -67,6 +68,10 @@ def create_user(db: Session, username: str, password: str, *, is_superuser: bool
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
if auth_enabled():
user_workspace.ensure_default_code_main(
user_workspace.user_workspace_root(user.id, user.username)
)
return user return user
@@ -145,7 +150,7 @@ def delete_user(db: Session, user_id: int) -> bool:
def invite_required() -> bool: def invite_required() -> bool:
return os.environ.get("AUTH_INVITE_ONLY", "false").strip().lower() in ("1", "true", "yes", "on") return os.environ.get("AUTH_INVITE_ONLY", "true").strip().lower() in ("1", "true", "yes", "on")
def create_invite(db: Session, email: str, invited_by_user_id: int | None = None, expires_days: int = 7) -> InviteToken: def create_invite(db: Session, email: str, invited_by_user_id: int | None = None, expires_days: int = 7) -> InviteToken:

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import re
from pathlib import Path
from editor_app import config
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
def safe_workspace_leaf(username: str, user_id: int) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
return f"{base}-{user_id}"
def user_workspace_root(user_id: int, username: str, workspace_root: Path | None = None) -> Path:
root = (workspace_root or config.WORKSPACE_ROOT).resolve()
return root / "users" / safe_workspace_leaf(username, user_id)
def ensure_default_code_main(user_root: Path) -> None:
"""Ensure code/ exists and add a starter main.py when missing."""
code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")

View File

@@ -89,7 +89,11 @@ class TextEditor {
this.updateWorkspaceBanner(); this.updateWorkspaceBanner();
this.prewarmPyWorker(); this.prewarmPyWorker();
this.fetchViewerRole() this.fetchViewerRole()
.finally(() => this.loadInitialDirectoryState().then(() => this.restoreSessionTabs())); .finally(() =>
this.loadInitialDirectoryState().then(() =>
this.restoreSessionTabs().then(() => this.ensureDefaultMainOpen())
)
);
} }
async fetchViewerRole() { async fetchViewerRole() {
@@ -308,6 +312,13 @@ class TextEditor {
this.saveSessionState(); this.saveSessionState();
} }
async ensureDefaultMainOpen() {
if (this.openTabs.length > 0) {
return;
}
await this.openFile('code/main.py');
}
async restoreExplorerState(session) { async restoreExplorerState(session) {
const expanded = Array.isArray(session.expandedDirs) const expanded = Array.isArray(session.expandedDirs)
? session.expandedDirs.filter((path) => typeof path === 'string') ? session.expandedDirs.filter((path) => typeof path === 'string')

View File

@@ -31,6 +31,32 @@ def test_auth_status_public(tmp_path, monkeypatch):
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False} assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
def test_auth_invite_only_defaults_on(monkeypatch, tmp_path):
"""When AUTH_INVITE_ONLY is unset, require invites (deployment-safe default)."""
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.delenv("AUTH_INVITE_ONLY", raising=False)
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
with TestClient(main.app) as client:
st = client.get("/api/auth/status")
assert st.status_code == 200
assert st.json()["invite_required"] is True
denied = client.post("/api/auth/register", json={"username": "noc", "password": "password99"})
assert denied.status_code == 403
def test_register_login_and_api_access(tmp_path, monkeypatch): def test_register_login_and_api_access(tmp_path, monkeypatch):
with TestClient( with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
@@ -58,6 +84,25 @@ def test_register_login_and_api_access(tmp_path, monkeypatch):
assert client.get("/api/files").status_code == 401 assert client.get("/api/files").status_code == 401
def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
) as client:
reg = client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
assert reg.status_code == 200
assert reg.json()["username"] == "alice"
uid = reg.json()["id"]
on_disk = tmp_path / "users" / f"alice-{uid}" / "code" / "main.py"
assert on_disk.is_file()
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n'
assert client.post("/api/auth/login", json={"username": "alice", "password": "password99"}).status_code == 200
fetched = client.get("/api/file/code/main.py")
assert fetched.status_code == 200
assert fetched.json()["filename"] == "main.py"
assert 'Hello, World!' in fetched.json()["content"]
def test_second_user_not_superuser(tmp_path, monkeypatch): def test_second_user_not_superuser(tmp_path, monkeypatch):
with TestClient( with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")