M1.5 backend: admin role + DB-backed settings, DB-URL-only install
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 31s

- users.is_admin; first registered user becomes admin; registration gated by the
  allow_registration setting (first account always allowed). is_admin in
  /api/auth/* responses; require_admin guard (live DB check).
- settings table + code registry (site_name, allow_registration, session_ttl_days)
  with typed defaults — empty table = all defaults (rule 26). get/set/validate
  service; GET /api/config (public) + GET/PATCH /api/settings (admin), live
  session-TTL apply with no restart (rule 25).
- Cookie-signing secret now persisted in the DB (before_serving load-or-create),
  so sessions survive restarts with no volume. Config: DATABASE_URL is the only
  required env; SECRET_KEY + DATA_DIR are optional break-glass items.
- Migration 0003; DB-free tests for settings validation + admin guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 18:25:04 -04:00
co-authored by Claude Opus 4.8
parent 0f604f9a26
commit b46bda38ee
11 changed files with 413 additions and 44 deletions
+32
View File
@@ -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")
+33 -6
View File
@@ -1,34 +1,61 @@
from __future__ import annotations from __future__ import annotations
import os import os
import secrets
from datetime import timedelta
from quart import Quart, jsonify, send_from_directory from quart import Quart, jsonify, send_from_directory
from . import __version__ from . import __version__
from .auth import bp as auth_bp from .auth import bp as auth_bp
from .config import Config from .config import Config
from .db import session_scope
from .notes import bp as notes_bp 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") STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
def create_app() -> Quart: def create_app() -> Quart:
# static_folder=None: the SPA catch-all below owns static serving instead of # static_folder=None: the SPA catch-all below owns static serving.
# Quart's default /static handler.
app = Quart(__name__, static_folder=None) 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["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(auth_bp)
app.register_blueprint(notes_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") @app.get("/api/health")
async def health(): async def health():
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]}) return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
# Serve the built Vue SPA (baked into static/ by the Docker build) with a @app.get("/api/config")
# history-fallback to index.html. In dev the Vite server proxies /api here, so async def public_config():
# a missing static/ dir is expected and simply 404s the frontend. # 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("/", defaults={"path": ""})
@app.get("/<path:path>") @app.get("/<path:path>")
async def spa(path: str): async def spa(path: str):
+51 -7
View File
@@ -4,11 +4,12 @@ import functools
import uuid import uuid
from quart import Blueprint, g, jsonify, request, session from quart import Blueprint, g, jsonify, request, session
from sqlalchemy import select from sqlalchemy import func, select
from .db import session_scope from .db import session_scope
from .models.user import User from .models.user import User
from .security import hash_password, verify_password from .security import hash_password, verify_password
from .settings import get_setting
bp = Blueprint("auth", __name__, url_prefix="/api/auth") bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -22,22 +23,52 @@ def _serialize_user(user: User) -> dict:
"email": user.email, "email": user.email,
"display_name": user.display_name, "display_name": user.display_name,
"email_verified": user.email_verified, "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): def login_required(fn):
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view.""" """Guard: 401 unless a valid session is present. Sets g.user_id for the view."""
@functools.wraps(fn) @functools.wraps(fn)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
raw = session.get(SESSION_KEY) uid = _session_user_id()
if not raw: if uid is None:
return jsonify({"error": "authentication required"}), 401 return jsonify({"error": "authentication required"}), 401
try: g.user_id = uid
g.user_id = uuid.UUID(raw) return await fn(*args, **kwargs)
except (ValueError, TypeError):
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) session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401 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 await fn(*args, **kwargs)
return wrapper return wrapper
@@ -58,14 +89,26 @@ async def register():
display_name = email.split("@", 1)[0] display_name = email.split("@", 1)[0]
async with session_scope() as db: 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)) existing = await db.scalar(select(User).where(User.email == email))
if existing is not None: if existing is not None:
return jsonify({"error": "an account with that email already exists"}), 409 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) db.add(user)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
session[SESSION_KEY] = str(user.id) session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user)), 201 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): 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 return jsonify({"error": "invalid email or password"}), 401
session[SESSION_KEY] = str(user.id) session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
+14 -29
View File
@@ -1,52 +1,37 @@
from __future__ import annotations from __future__ import annotations
import os import os
import secrets
from pathlib import Path from pathlib import Path
class Config: class Config:
"""Runtime configuration. """Bootstrap configuration.
Values come from environment variables with working local-dev defaults, so the For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var —
app boots with zero configuration (family rule 26 — integrations default to every other tunable lives in the DB-backed Settings UI (rule 25). The remaining
working, never coerce setup). As the product grows, anything an operator wants env vars are optional "break-glass" / bootstrap items:
to tune moves into a DB-backed Settings UI (rule 25); env is bootstrap only.
- ``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") DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync")
# Empty -> defaults to <DATA_DIR>/media (see media_root()).
MEDIA_ROOT = os.environ.get("THOUGHTSYNC_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( DATABASE_URL = os.environ.get(
"THOUGHTSYNC_DATABASE_URL", "THOUGHTSYNC_DATABASE_URL",
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync", "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 @classmethod
def media_root(cls) -> Path: def media_root(cls) -> Path:
return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media")) return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media"))
@classmethod @classmethod
def secret_key(cls) -> bytes: def secret_key_env(cls) -> str | None:
"""Secret used to sign the auth session cookie. """Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
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
+1 -1
View File
@@ -3,4 +3,4 @@
Imported for side effects only (model registration on Base.metadata). 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
+22
View File
@@ -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()
)
+2
View File
@@ -17,6 +17,8 @@ class User(Base):
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere. # CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True) email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) 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). # Nullable: leaves room for external-identity-only accounts later (rule 26).
password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True) password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
display_name: Mapped[str] = mapped_column(Text(), nullable=False) display_name: Mapped[str] = mapped_column(Text(), nullable=False)
+167
View File
@@ -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
+44
View File
@@ -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})
+21
View File
@@ -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
+25
View File
@@ -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 == {}