Add local Pinscope multi-user auth for shared projects.

Self-host email/password accounts enable the existing collaborator
invite flow without Clerk; first admin inherits users/local projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 14:31:37 +02:00
co-authored by Cursor
parent c5b260b404
commit 5a69b380da
22 changed files with 1045 additions and 116 deletions
+204
View File
@@ -0,0 +1,204 @@
"""Local Pinscope user directory (self-host auth, no Clerk).
Users live under ``data/auth/users/{user_id}.json`` with an email index.
Passwords use stdlib ``hashlib.scrypt``.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import secrets
import shutil
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from backend.config import settings
logger = logging.getLogger(__name__)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass
class LocalUser:
user_id: str
email: str
name: str | None
password_hash: str
is_admin: bool = False
created_at: str = ""
def public(self) -> dict:
return {
"user_id": self.user_id,
"email": self.email,
"name": self.name,
"is_admin": self.is_admin,
}
def _auth_root() -> Path:
root = Path(settings.data_dir) / "auth"
(root / "users").mkdir(parents=True, exist_ok=True)
(root / "by_email").mkdir(parents=True, exist_ok=True)
return root
def _email_key(email: str) -> str:
return email.strip().lower()
def _email_path(email: str) -> Path:
# Filesystem-safe key from normalized email
key = _email_key(email).replace("/", "_")
return _auth_root() / "by_email" / f"{key}.json"
def _user_path(user_id: str) -> Path:
return _auth_root() / "users" / f"{user_id}.json"
def hash_password(password: str, *, salt: bytes | None = None) -> str:
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return f"scrypt${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
algo, salt_hex, digest_hex = encoded.split("$", 2)
except ValueError:
return False
if algo != "scrypt":
return False
salt = bytes.fromhex(salt_hex)
check = hash_password(password, salt=salt)
return secrets.compare_digest(check, encoded)
def validate_email(email: str) -> str:
e = email.strip().lower()
if not _EMAIL_RE.match(e):
raise ValueError("Invalid email address")
return e
def validate_password(password: str) -> None:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
def get_user(user_id: str) -> LocalUser | None:
path = _user_path(user_id)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return LocalUser(**data)
def find_by_email(email: str) -> LocalUser | None:
path = _email_path(email)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
uid = data.get("user_id")
if not uid:
return None
return get_user(uid)
def list_users() -> list[LocalUser]:
users_dir = _auth_root() / "users"
out: list[LocalUser] = []
for path in sorted(users_dir.glob("*.json")):
try:
out.append(LocalUser(**json.loads(path.read_text(encoding="utf-8"))))
except Exception:
logger.exception("Skipping corrupt user file %s", path)
return out
def _save_user(user: LocalUser) -> None:
_user_path(user.user_id).write_text(
json.dumps(asdict(user), indent=2) + "\n", encoding="utf-8"
)
_email_path(user.email).write_text(
json.dumps({"user_id": user.user_id}) + "\n", encoding="utf-8"
)
def user_count() -> int:
return len(list((_auth_root() / "users").glob("*.json")))
def _migrate_local_projects(new_owner_id: str) -> int:
"""Move ``users/local/projects/*`` under the first admin, if present."""
local_projects = Path(settings.data_dir) / "users" / "local" / "projects"
if not local_projects.is_dir():
return 0
dest_root = Path(settings.data_dir) / "users" / new_owner_id / "projects"
dest_root.mkdir(parents=True, exist_ok=True)
moved = 0
for child in local_projects.iterdir():
if not child.is_dir():
continue
target = dest_root / child.name
if target.exists():
continue
shutil.move(str(child), str(target))
moved += 1
logger.info("Migrated project %s → user %s", child.name, new_owner_id)
return moved
def create_user(email: str, password: str, name: str | None = None) -> LocalUser:
email = validate_email(email)
validate_password(password)
if find_by_email(email):
raise ValueError("An account with that email already exists")
from datetime import datetime, timezone
first = user_count() == 0
admin_emails = {
e.strip().lower()
for e in (settings.auth_admin_emails or "").split(",")
if e.strip()
}
is_admin = first or email in admin_emails
user = LocalUser(
user_id="usr_" + uuid.uuid4().hex,
email=email,
name=(name or "").strip() or None,
password_hash=hash_password(password),
is_admin=is_admin,
created_at=datetime.now(timezone.utc).isoformat(),
)
_save_user(user)
if first:
try:
n = _migrate_local_projects(user.user_id)
if n:
logger.info("First admin inherited %s local project(s)", n)
except Exception:
logger.exception("Failed to migrate users/local projects")
return user
def authenticate(email: str, password: str) -> LocalUser | None:
user = find_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user