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
+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