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:
@@ -0,0 +1,282 @@
|
||||
-- ============================================================================
|
||||
-- ctxd — Context Daemon Schema
|
||||
-- SQLite 3.x, WAL mode, FTS5
|
||||
-- (Legacy fallback for local dev when DATABASE_URL is not set)
|
||||
-- ============================================================================
|
||||
|
||||
-- WAL for concurrent reads during writes; foreign keys enforced
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ============================================================================
|
||||
-- USERS
|
||||
-- ============================================================================
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'contributor'
|
||||
CHECK (role IN ('admin', 'contributor', 'service')),
|
||||
token_hash TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id));
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECTS
|
||||
-- ============================================================================
|
||||
CREATE TABLE projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
metadata_tags TEXT DEFAULT '[]',
|
||||
shared_version INTEGER NOT NULL DEFAULT 0,
|
||||
auto_sync INTEGER NOT NULL DEFAULT 0,
|
||||
sync_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT PERMISSIONS (admin overrides all)
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL DEFAULT 'editor'
|
||||
CHECK (permission IN ('owner', 'editor', 'viewer')),
|
||||
UNIQUE(project_id, user_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER PROFILES (personal context — timezone, preferences, style)
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT CONTEXT — THE AUTHORITATIVE SHARED COPY
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_context (
|
||||
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- CONTEXT FILES — individual files within a project
|
||||
-- ============================================================================
|
||||
CREATE TABLE context_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(project_id, file_path)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER WORKSPACES — per-user forks of a project
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_workspaces (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress', 'submitted', 'merged', 'abandoned')),
|
||||
base_version INTEGER NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(user_id, project_id, status)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- WORKSPACE FILES — per-user fork of context_files
|
||||
-- ============================================================================
|
||||
CREATE TABLE workspace_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(workspace_id, file_path)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- CHANGE REQUESTS — submit / review / merge workflow
|
||||
-- ============================================================================
|
||||
CREATE TABLE change_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
||||
submitted_by TEXT NOT NULL REFERENCES users(user_id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'merged')),
|
||||
diff_summary TEXT,
|
||||
target_version INTEGER NOT NULL,
|
||||
base_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- REVIEWS — approvals/rejections on change requests
|
||||
-- ============================================================================
|
||||
CREATE TABLE reviews (
|
||||
review_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE,
|
||||
reviewer_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
|
||||
comments TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(request_id, reviewer_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- SNAPSHOTS — point-in-time copies of project or workspace content
|
||||
-- ============================================================================
|
||||
CREATE TABLE snapshots (
|
||||
snapshot_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES user_workspaces(workspace_id) ON DELETE SET NULL,
|
||||
version_from INTEGER,
|
||||
version_to INTEGER,
|
||||
storage_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIT LOG — append-only (INSERT only, never UPDATE or DELETE)
|
||||
-- ============================================================================
|
||||
CREATE TABLE audit_log (
|
||||
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
agent_id TEXT NOT NULL DEFAULT 'cli',
|
||||
session_id TEXT,
|
||||
project_id TEXT REFERENCES projects(project_id),
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN (
|
||||
'read', 'update', 'create', 'delete',
|
||||
'submit', 'approve', 'reject', 'merge',
|
||||
'sync', 'search', 'export', 'restore',
|
||||
'login', 'logout', 'import'
|
||||
)),
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_user ON audit_log (user_id, created_at);
|
||||
CREATE INDEX idx_audit_project ON audit_log (project_id, created_at);
|
||||
CREATE INDEX idx_audit_agent ON audit_log (agent_id, created_at);
|
||||
CREATE INDEX idx_audit_op ON audit_log (operation, created_at);
|
||||
|
||||
-- Trigger: audit_log is append-only
|
||||
CREATE TRIGGER tr_audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no updates allowed');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no deletes allowed');
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- FULL-TEXT SEARCH (FTS5)
|
||||
-- ============================================================================
|
||||
CREATE VIRTUAL TABLE fts_context USING fts5(
|
||||
content,
|
||||
project_id UNINDEXED,
|
||||
file_path UNINDEXED,
|
||||
source_type UNINDEXED,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS index in sync with project_context
|
||||
CREATE TRIGGER tr_fts_project_context_insert AFTER INSERT ON project_context
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_update AFTER UPDATE ON project_context
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_delete AFTER DELETE ON project_context
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
-- Triggers for context_files
|
||||
CREATE TRIGGER tr_fts_context_files_insert AFTER INSERT ON context_files
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_update AFTER UPDATE ON context_files
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_delete AFTER DELETE ON context_files
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
END;
|
||||
|
||||
-- Triggers for user_profiles
|
||||
CREATE TRIGGER tr_fts_user_profiles_insert AFTER INSERT ON user_profiles
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_user_profiles_update AFTER UPDATE ON user_profiles
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid + 2000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile');
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED DATA (for development / first-run)
|
||||
-- ============================================================================
|
||||
INSERT INTO users (user_id, display_name, role) VALUES
|
||||
('admin', 'Administrator', 'admin'),
|
||||
('hermes-gateway', 'Hermes Agent', 'service');
|
||||
|
||||
INSERT INTO projects (project_id, display_name, description) VALUES
|
||||
('welcome', 'Welcome', 'Getting started guide and documentation for ctxd'),
|
||||
('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system');
|
||||
|
||||
-- Project context is seeded by the Python init code (cmd_init)
|
||||
-- to ensure real newlines, not literal backslash-n from SQL strings.
|
||||
Reference in New Issue
Block a user