diff --git a/alembic/versions/0003_roles_settings.py b/alembic/versions/0003_roles_settings.py new file mode 100644 index 0000000..50a2d12 --- /dev/null +++ b/alembic/versions/0003_roles_settings.py @@ -0,0 +1,32 @@ +"""roles + settings + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-07-19 + +Adds the admin role bit and the DB-backed settings table. No rows are seeded — +defaults live in the code registry (thoughtsync.settings), so an empty table +means every setting is at its default. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false())) + op.create_table( + "settings", + sa.Column("key", sa.Text(), primary_key=True), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("settings") + op.drop_column("users", "is_admin") diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index 0c8551d..4a145de 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -1,34 +1,61 @@ from __future__ import annotations import os +import secrets +from datetime import timedelta from quart import Quart, jsonify, send_from_directory from . import __version__ from .auth import bp as auth_bp from .config import Config +from .db import session_scope from .notes import bp as notes_bp +from .settings import get_public_config, get_setting, load_or_create_secret_key +from .settings_api import bp as settings_bp STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") def create_app() -> Quart: - # static_folder=None: the SPA catch-all below owns static serving instead of - # Quart's default /static handler. + # static_folder=None: the SPA catch-all below owns static serving. app = Quart(__name__, static_folder=None) - app.secret_key = Config.secret_key() + # Ephemeral/env secret so the app (and DB-free unit tests) construct without a + # database. before_serving swaps in the real, DB-persisted key before serving. + app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48) app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__) + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) app.register_blueprint(auth_bp) app.register_blueprint(notes_bp) + app.register_blueprint(settings_bp) + + @app.before_serving + async def _bootstrap() -> None: + # Load (or generate + persist) the real signing secret and the live session + # lifetime from the DB, before any request is served. + async with session_scope() as db: + app.secret_key = await load_or_create_secret_key(db) + try: + days = int(await get_setting(db, "session_ttl_days")) + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days) + except (ValueError, TypeError, KeyError): + pass @app.get("/api/health") async def health(): return jsonify({"status": "ok", "version": app.config["APP_VERSION"]}) - # Serve the built Vue SPA (baked into static/ by the Docker build) with a - # history-fallback to index.html. In dev the Vite server proxies /api here, so - # a missing static/ dir is expected and simply 404s the frontend. + @app.get("/api/config") + async def public_config(): + # Public: the login/register screen reads site name + whether signups are open. + async with session_scope() as db: + data = await get_public_config(db) + data["version"] = app.config["APP_VERSION"] + return jsonify(data) + @app.get("/", defaults={"path": ""}) @app.get("/") async def spa(path: str): diff --git a/src/thoughtsync/auth.py b/src/thoughtsync/auth.py index 4a7f188..9114a71 100644 --- a/src/thoughtsync/auth.py +++ b/src/thoughtsync/auth.py @@ -4,11 +4,12 @@ import functools import uuid from quart import Blueprint, g, jsonify, request, session -from sqlalchemy import select +from sqlalchemy import func, select from .db import session_scope from .models.user import User from .security import hash_password, verify_password +from .settings import get_setting bp = Blueprint("auth", __name__, url_prefix="/api/auth") @@ -22,22 +23,52 @@ def _serialize_user(user: User) -> dict: "email": user.email, "display_name": user.display_name, "email_verified": user.email_verified, + "is_admin": user.is_admin, } +def _session_user_id() -> uuid.UUID | None: + raw = session.get(SESSION_KEY) + if not raw: + return None + try: + return uuid.UUID(raw) + except (ValueError, TypeError): + session.pop(SESSION_KEY, None) + return None + + def login_required(fn): """Guard: 401 unless a valid session is present. Sets g.user_id for the view.""" @functools.wraps(fn) async def wrapper(*args, **kwargs): - raw = session.get(SESSION_KEY) - if not raw: + uid = _session_user_id() + if uid is None: return jsonify({"error": "authentication required"}), 401 - try: - g.user_id = uuid.UUID(raw) - except (ValueError, TypeError): - session.pop(SESSION_KEY, None) + g.user_id = uid + return await fn(*args, **kwargs) + + return wrapper + + +def require_admin(fn): + """Guard: 401 unauthenticated, 403 non-admin. Checks is_admin live from the DB + so a demoted admin loses access immediately.""" + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + uid = _session_user_id() + if uid is None: return jsonify({"error": "authentication required"}), 401 + async with session_scope() as db: + user = await db.get(User, uid) + if user is None: + session.pop(SESSION_KEY, None) + return jsonify({"error": "authentication required"}), 401 + if not user.is_admin: + return jsonify({"error": "admin access required"}), 403 + g.user_id = uid return await fn(*args, **kwargs) return wrapper @@ -58,14 +89,26 @@ async def register(): display_name = email.split("@", 1)[0] async with session_scope() as db: + user_count = await db.scalar(select(func.count()).select_from(User)) or 0 + is_first = user_count == 0 + # The first account bootstraps the admin and is always allowed, even when + # registration is otherwise closed. + if not is_first and not await get_setting(db, "allow_registration"): + return jsonify({"error": "registration is closed"}), 403 existing = await db.scalar(select(User).where(User.email == email)) if existing is not None: return jsonify({"error": "an account with that email already exists"}), 409 - user = User(email=email, password_hash=hash_password(password), display_name=display_name) + user = User( + email=email, + password_hash=hash_password(password), + display_name=display_name, + is_admin=is_first, + ) db.add(user) await db.commit() await db.refresh(user) session[SESSION_KEY] = str(user.id) + session.permanent = True return jsonify(_serialize_user(user)), 201 @@ -80,6 +123,7 @@ async def login(): if user is None or not user.password_hash or not verify_password(password, user.password_hash): return jsonify({"error": "invalid email or password"}), 401 session[SESSION_KEY] = str(user.id) + session.permanent = True return jsonify(_serialize_user(user)) diff --git a/src/thoughtsync/config.py b/src/thoughtsync/config.py index a9f3cf8..1e5f9b8 100644 --- a/src/thoughtsync/config.py +++ b/src/thoughtsync/config.py @@ -1,52 +1,37 @@ from __future__ import annotations import os -import secrets from pathlib import Path class Config: - """Runtime configuration. + """Bootstrap configuration. - Values come from environment variables with working local-dev defaults, so the - app boots with zero configuration (family rule 26 — integrations default to - working, never coerce setup). As the product grows, anything an operator wants - to tune moves into a DB-backed Settings UI (rule 25); env is bootstrap only. + For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var — + every other tunable lives in the DB-backed Settings UI (rule 25). The remaining + env vars are optional "break-glass" / bootstrap items: + + - ``THOUGHTSYNC_SECRET_KEY`` — optional override for the cookie-signing secret. + If unset, a key is generated and persisted in the DB (see + ``thoughtsync.settings.load_or_create_secret_key``), so sessions survive + restarts with no volume required. + - ``THOUGHTSYNC_DATA_DIR`` — optional, defaults to ``/var/thoughtsync``. Only + used for uploaded media (M2); irrelevant to a basic text-notes install. """ - # Where runtime-generated secrets + uploaded media live. DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync") - # Empty -> defaults to /media (see media_root()). MEDIA_ROOT = os.environ.get("THOUGHTSYNC_MEDIA_ROOT", "") - # postgresql+asyncpg URL. Mirrors alembic.ini's local-dev default. DATABASE_URL = os.environ.get( "THOUGHTSYNC_DATABASE_URL", "postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync", ) - # Auth session cookie: a capture app should rarely log you out, so keep it long. - AUTH_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 days - @classmethod def media_root(cls) -> Path: return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media")) @classmethod - def secret_key(cls) -> bytes: - """Secret used to sign the auth session cookie. - - Read from env if set; otherwise read/generate a persistent key file under - DATA_DIR so signed sessions survive restarts (no forced re-login on deploy). - """ - env = os.environ.get("THOUGHTSYNC_SECRET_KEY") - if env: - return env.encode() - data_dir = Path(cls.DATA_DIR) - data_dir.mkdir(parents=True, exist_ok=True) - key_file = data_dir / "secret_key" - if key_file.exists(): - return key_file.read_bytes() - key = secrets.token_bytes(32) - key_file.write_bytes(key) - return key + def secret_key_env(cls) -> str | None: + """Optional break-glass override for the cookie-signing secret.""" + return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py index 0e60076..d2136b5 100644 --- a/src/thoughtsync/models/all.py +++ b/src/thoughtsync/models/all.py @@ -3,4 +3,4 @@ Imported for side effects only (model registration on Base.metadata). """ -from . import group, note, share, user # noqa: F401 +from . import group, note, settings, share, user # noqa: F401 diff --git a/src/thoughtsync/models/settings.py b/src/thoughtsync/models/settings.py new file mode 100644 index 0000000..7525a90 --- /dev/null +++ b/src/thoughtsync/models/settings.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class Setting(Base): + """A single persisted key/value setting. Values are JSON-encoded text; the + typed defaults + metadata live in the code registry (thoughtsync.settings), so + an empty table means "all defaults" (rule 26).""" + + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(Text(), primary_key=True) + value: Mapped[str] = mapped_column(Text(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/src/thoughtsync/models/user.py b/src/thoughtsync/models/user.py index a6fe3ad..aad2fc9 100644 --- a/src/thoughtsync/models/user.py +++ b/src/thoughtsync/models/user.py @@ -17,6 +17,8 @@ class User(Base): # CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere. email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True) email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) + # First registered user becomes admin (see auth.register); admin gates Settings. + is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # Nullable: leaves room for external-identity-only accounts later (rule 26). password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True) display_name: Mapped[str] = mapped_column(Text(), nullable=False) diff --git a/src/thoughtsync/settings.py b/src/thoughtsync/settings.py new file mode 100644 index 0000000..053932c --- /dev/null +++ b/src/thoughtsync/settings.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal + +from .models.settings import Setting + +SettingType = Literal["string", "bool", "int"] + + +@dataclass(frozen=True) +class SettingDef: + key: str + type: SettingType + default: Any + label: str + description: str + group: str + + +# The source of truth for every user-facing setting. Add a row here and it appears +# in the admin Settings UI with a working default — no migration, no env var. +REGISTRY: list[SettingDef] = [ + SettingDef( + "site_name", "string", "ThoughtSync", "Site name", "Shown in the header and the browser tab.", "General" + ), + SettingDef( + "allow_registration", + "bool", + True, + "Allow new registrations", + "When off, only existing users can sign in. The first account is always allowed.", + "Access", + ), + SettingDef( + "session_ttl_days", + "int", + 30, + "Session length (days)", + "How long a signed-in session stays valid before another login is required.", + "Access", + ), +] + +_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY} + +# Internal, non-UI reserved key: the persisted cookie-signing secret. Stored in the +# same table but never listed in the registry, so it never shows in the Settings UI. +SECRET_KEY_SETTING = "secret_key" + + +def _coerce_bool(raw: Any) -> bool: + if isinstance(raw, bool): + return raw + if isinstance(raw, str): + return raw.strip().lower() in ("1", "true", "yes", "on") + return bool(raw) + + +def _coerce(defn: SettingDef, raw: Any) -> Any: + if defn.type == "bool": + return _coerce_bool(raw) + if defn.type == "int": + try: + return int(raw) + except (ValueError, TypeError): + return defn.default + return str(raw) + + +async def _load_raw(db, key: str) -> Any: + row = await db.get(Setting, key) + if row is None: + return None + try: + return json.loads(row.value) + except (ValueError, TypeError): + return None + + +async def _upsert(db, key: str, value: Any) -> None: + row = await db.get(Setting, key) + payload = json.dumps(value) + if row is None: + db.add(Setting(key=key, value=payload)) + else: + row.value = payload + row.updated_at = datetime.now(timezone.utc) + + +async def get_setting(db, key: str) -> Any: + defn = _BY_KEY.get(key) + if defn is None: + raise KeyError(key) + raw = await _load_raw(db, key) + return defn.default if raw is None else _coerce(defn, raw) + + +async def get_public_config(db) -> dict: + """Non-sensitive settings the unauthenticated login/register screen needs.""" + return { + "site_name": await get_setting(db, "site_name"), + "allow_registration": await get_setting(db, "allow_registration"), + } + + +async def get_admin_settings(db) -> list[dict]: + """Every registry setting with its current value + metadata, for the admin UI.""" + result: list[dict] = [] + for d in REGISTRY: + result.append( + { + "key": d.key, + "type": d.type, + "value": await get_setting(db, d.key), + "default": d.default, + "label": d.label, + "description": d.description, + "group": d.group, + } + ) + return result + + +def validate_updates(updates: dict) -> tuple[dict, str | None]: + """Coerce/validate a {key: value} dict against the registry. Returns + (clean_values, error_message). An unknown key or a bad int is rejected.""" + clean: dict = {} + for key, val in updates.items(): + defn = _BY_KEY.get(key) + if defn is None: + return {}, f"unknown setting: {key}" + if defn.type == "int": + try: + clean[key] = int(val) + except (ValueError, TypeError): + return {}, f"{defn.label} must be a whole number" + elif defn.type == "bool": + clean[key] = _coerce_bool(val) + else: + clean[key] = str(val) + return clean, None + + +async def set_settings(db, updates: dict) -> None: + for key, value in updates.items(): + await _upsert(db, key, value) + + +async def load_or_create_secret_key(db) -> str: + """Return the persisted cookie-signing secret, generating + storing one on first + run. Keeps sessions valid across restarts with no env var or volume required.""" + row = await db.get(Setting, SECRET_KEY_SETTING) + if row is not None: + try: + val = json.loads(row.value) + if isinstance(val, str) and val: + return val + except (ValueError, TypeError): + pass + key = secrets.token_urlsafe(48) + await _upsert(db, SECRET_KEY_SETTING, key) + await db.commit() + return key diff --git a/src/thoughtsync/settings_api.py b/src/thoughtsync/settings_api.py new file mode 100644 index 0000000..4efeca1 --- /dev/null +++ b/src/thoughtsync/settings_api.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import timedelta + +from quart import Blueprint, current_app, jsonify, request + +from .auth import require_admin +from .db import session_scope +from .settings import get_admin_settings, set_settings, validate_updates + +bp = Blueprint("settings", __name__, url_prefix="/api/settings") + + +@bp.get("") +@require_admin +async def list_settings(): + async with session_scope() as db: + return jsonify({"settings": await get_admin_settings(db)}) + + +@bp.patch("") +@require_admin +async def update_settings(): + raw = await request.get_json(silent=True) + data = raw if isinstance(raw, dict) else {} + # Accept either {settings: {...}} or a bare {key: value} object. + updates = data.get("settings") if isinstance(data.get("settings"), dict) else data + if not isinstance(updates, dict): + return jsonify({"error": "expected an object of settings"}), 400 + + clean, error = validate_updates(updates) + if error is not None: + return jsonify({"error": error}), 400 + + async with session_scope() as db: + await set_settings(db, clean) + await db.commit() + result = await get_admin_settings(db) + + # Apply the live-tunable knob without a restart (rule 25). + if "session_ttl_days" in clean: + current_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(clean["session_ttl_days"])) + + return jsonify({"settings": result}) diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..09109dd --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,21 @@ +import pytest + +from thoughtsync.app import create_app + + +@pytest.fixture +def app(): + return create_app() + + +async def test_settings_requires_auth(app): + # require_admin returns 401 before any DB access when unauthenticated. + client = app.test_client() + resp = await client.get("/api/settings") + assert resp.status_code == 401 + + +async def test_settings_patch_requires_auth(app): + client = app.test_client() + resp = await client.patch("/api/settings", json={"site_name": "x"}) + assert resp.status_code == 401 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..4dd9b8c --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,25 @@ +from thoughtsync.settings import REGISTRY, validate_updates + + +def test_registry_has_expected_keys(): + keys = {d.key for d in REGISTRY} + assert {"site_name", "allow_registration", "session_ttl_days"} <= keys + + +def test_validate_rejects_unknown_key(): + clean, error = validate_updates({"nope": 1}) + assert error is not None + assert clean == {} + + +def test_validate_coerces_bool_and_int(): + clean, error = validate_updates({"allow_registration": "true", "session_ttl_days": "45"}) + assert error is None + assert clean["allow_registration"] is True + assert clean["session_ttl_days"] == 45 + + +def test_validate_rejects_bad_int(): + clean, error = validate_updates({"session_ttl_days": "not-a-number"}) + assert error is not None + assert clean == {}