feat: PostgreSQL migration, OAuth write MCP, Streamable HTTP, env-driven config, admin UI, landing page

- Migrate database from SQLite to PostgreSQL 16 (dual-backend with SQLite fallback)
- Add Streamable HTTP MCP transport (replaces SSE): /readonly/mcp, /write/mcp, /mcp
- Add OAuth ctxd.write scope and public write MCP surface
- Add ctxd.write token validation (write-scoped tokens only on /write/mcp)
- Add env-driven configuration (.env file with env var precedence over ctxd.yaml)
- Add PostgreSQL to docker-compose.yml with healthcheck
- Add psycopg dependency, migration script (SQLite → PostgreSQL)
- Add admin UI: projects tab with typed-confirm delete, user management (list/manage subtabs)
- Add OAuth client management: create, list, revoke (UI, CLI, API)
- Add user active/inactive lifecycle (PATCH/DELETE APIs)
- Add public landing page with themed login form (cookie-based session)
- Add get_client_guide MCP tool (locked LLM-CLIENT.MD in ctxd-docs project)
- Add DELETE /projects/<id> endpoint with cascading deletes
- Add project_delete to db.py with FK ON DELETE SET NULL for audit_log
- Add cookie-based session auth (ctxd_session cookie on login)
- Add landing.html (public host) vs ui.html (internal dashboard)
- Add schema_sqlite.sql for SQLite fallback
- Add auth_password.py (PBKDF2-SHA256 password hashing)
- Add .env.example template with all documented env vars
- Add README.md with full setup, config, API, CLI, and troubleshooting docs
- Add SKILL.md (canonical LLM client guide, lives in project root)
- Update Traefik template: route everything except /mcp
- Update OAuth discovery: advertise ctxd.write scope, /readonly/mcp resource
- Update Hermes MCP config: /mcp endpoint with Bearer header
- Remove DB-level audit_log triggers (conflict with FK ON DELETE SET NULL)
- Remove SSE transport code (replaced by Streamable HTTP)
- Untrack __pycache__ and data/ctxd.db from git
This commit is contained in:
2026-06-24 22:50:54 +00:00
parent a9ccfa2694
commit fc1a2f5103
29 changed files with 4393 additions and 346 deletions
+110 -11
View File
@@ -1,13 +1,39 @@
"""
Configuration for ctxd — context daemon.
Environment variables take precedence over ctxd.yaml, which takes precedence
over built-in defaults. This allows fully env-driven deployments (Docker,
Compose, Kubernetes) while preserving file-based config for local dev.
"""
import os
from pathlib import Path
def _env_bool(key: str, fallback: bool = False) -> bool:
val = os.environ.get(key, "")
if not val:
return fallback
return val.lower() in ("1", "true", "yes", "on")
def _env_int(key: str, fallback: int) -> int:
val = os.environ.get(key, "")
if not val:
return fallback
try:
return int(val)
except ValueError:
return fallback
def _env_str(key: str, fallback: str = "") -> str:
return os.environ.get(key, fallback)
# Default home directory (~/.ctx) — overridable via CTXD_HOME env var
DEFAULT_HOME = Path(os.environ.get("CTXD_HOME", Path.home() / ".ctx"))
# Defaults for ctxd.yaml
# Built-in defaults (lowest precedence)
DEFAULT_CONFIG = {
"server": {
"host": "0.0.0.0",
@@ -20,6 +46,18 @@ DEFAULT_CONFIG = {
"auth": {
"enabled": False,
"api_key": "",
"external_readonly_key": "",
},
"oauth": {
"enabled": False,
"issuer": "",
"approval_key": "",
"approval_user_id": "admin",
"access_token_ttl_seconds": 3600,
"refresh_token_ttl_seconds": 2592000,
},
"web_sessions": {
"ttl_seconds": 604800,
},
"seed": {
"admin_user": "admin",
@@ -31,7 +69,10 @@ DEFAULT_CONFIG = {
class CtxConfig:
"""Holds resolved paths and config for a ctxd runtime."""
"""Holds resolved paths and config for a ctxd runtime.
Precedence: env var > ctxd.yaml value > built-in default.
"""
def __init__(self, home: Path | str | None = None, config: dict | None = None):
resolved = Path(home) if home else DEFAULT_HOME
@@ -59,31 +100,87 @@ class CtxConfig:
def config_path(self) -> Path:
return self.home / "ctxd.yaml"
# ── Config accessors ──────────────────────────────────────────
@property
def oauth_state_path(self) -> Path:
return self.home / "oauth_state.json"
@property
def web_sessions_path(self) -> Path:
return self.home / "web_sessions.json"
# ── Database ──────────────────────────────────────────────────
@property
def database_url(self) -> str:
return _env_str("DATABASE_URL")
@property
def use_postgres(self) -> bool:
return bool(self.database_url)
# ── Server ────────────────────────────────────────────────────
@property
def host(self) -> str:
return self._cfg.get("server", {}).get("host", "127.0.0.1")
return _env_str("CTXD_HOST", self._cfg.get("server", {}).get("host", "0.0.0.0"))
@property
def port(self) -> int:
return self._cfg.get("server", {}).get("port", 9091)
return _env_int("CTXD_PORT", self._cfg.get("server", {}).get("port", 9091))
@property
def log_level(self) -> str:
return _env_str("LOG_LEVEL", "info")
# ── Snapshots ─────────────────────────────────────────────────
@property
def min_snapshots(self) -> int:
return self._cfg.get("snapshots", {}).get("min_keep", 5)
return _env_int("SNAPSHOT_MIN_KEEP", self._cfg.get("snapshots", {}).get("min_keep", 5))
@property
def max_snapshots(self) -> int:
return self._cfg.get("snapshots", {}).get("max_keep", 25)
return _env_int("SNAPSHOT_MAX_KEEP", self._cfg.get("snapshots", {}).get("max_keep", 25))
# ── Auth ──────────────────────────────────────────────────────
@property
def auth_enabled(self) -> bool:
return self._cfg.get("auth", {}).get("enabled", False)
return _env_bool("CTXD_AUTH_ENABLED", self._cfg.get("auth", {}).get("enabled", False))
@property
def api_key(self) -> str:
return self._cfg.get("auth", {}).get("api_key", "")
return _env_str("CTXD_API_KEY", self._cfg.get("auth", {}).get("api_key", ""))
@property
def external_readonly_key(self) -> str:
return _env_str("CTXD_EXTERNAL_READONLY_KEY", self._cfg.get("auth", {}).get("external_readonly_key", ""))
# ── OAuth ─────────────────────────────────────────────────────
@property
def oauth_enabled(self) -> bool:
return _env_bool("OAUTH_ENABLED", self._cfg.get("oauth", {}).get("enabled", False))
@property
def oauth_issuer(self) -> str:
return _env_str("OAUTH_ISSUER", self._cfg.get("oauth", {}).get("issuer", ""))
@property
def oauth_approval_key(self) -> str:
return _env_str("OAUTH_APPROVAL_KEY", self._cfg.get("oauth", {}).get("approval_key", ""))
@property
def oauth_approval_user_id(self) -> str:
return _env_str("OAUTH_APPROVAL_USER_ID", self._cfg.get("oauth", {}).get("approval_user_id", "admin"))
@property
def oauth_access_token_ttl_seconds(self) -> int:
return _env_int("OAUTH_ACCESS_TOKEN_TTL", self._cfg.get("oauth", {}).get("access_token_ttl_seconds", 3600))
@property
def oauth_refresh_token_ttl_seconds(self) -> int:
return _env_int("OAUTH_REFRESH_TOKEN_TTL", self._cfg.get("oauth", {}).get("refresh_token_ttl_seconds", 2592000))
# ── Web Sessions ───────────────────────────────────────────────
@property
def web_session_ttl_seconds(self) -> int:
return _env_int("WEB_SESSION_TTL", self._cfg.get("web_sessions", {}).get("ttl_seconds", 604800))
# ── Bootstrap ─────────────────────────────────────────────────
def ensure_dirs(self):
@@ -93,7 +190,9 @@ class CtxConfig:
@classmethod
def from_home(cls, home: Path | str | None = None) -> "CtxConfig":
"""Load from ctxd.yaml if it exists, otherwise use defaults."""
"""Load from ctxd.yaml if it exists, otherwise use defaults.
Env vars always take precedence over yaml values at read time.
"""
home = Path(home).resolve() if home else DEFAULT_HOME
cfg_path = home / "ctxd.yaml"
if cfg_path.exists():
@@ -104,7 +203,7 @@ class CtxConfig:
return cls(home=str(home))
def save(self):
"""Write config to ctxd.yaml."""
"""Write config to ctxd.yaml. Rarely needed in env-driven deployments."""
import yaml
self.ensure_dirs()
with open(self.config_path, "w") as f: