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
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("/<path:path>")
async def spa(path: str):
+52 -8
View File
@@ -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))
+14 -29
View File
@@ -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 <DATA_DIR>/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
+1 -1
View File
@@ -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
+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.
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)
+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 == {}