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:
+110
-11
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user