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,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CTXD — Context Dossier</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--paper: #1e1e1e;
|
||||
--ink: #d4d4d4;
|
||||
--ink-dim: #888;
|
||||
--accent: #e5c07b;
|
||||
--accent3: #98c379;
|
||||
--border: #2a2a2a;
|
||||
--border-light: #333;
|
||||
--input-bg: #222;
|
||||
--hover: #252525;
|
||||
--danger: #e06c75;
|
||||
--font: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
html { height: 100%; font-size: 15px; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.landing {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.landing .subtitle {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.landing .description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing .description code {
|
||||
color: var(--accent);
|
||||
background: var(--input-bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-card label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 0.3rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-card input:focus { border-color: var(--accent); }
|
||||
|
||||
.login-card .actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
font-family: var(--font);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.5rem 0.85rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-light);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.login-card button:hover { background: var(--hover); }
|
||||
|
||||
.login-card button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-color: var(--accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-card button.primary:hover { background: #d4ae5c; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.6rem 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 200;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.error { border-color: var(--danger); color: var(--danger); }
|
||||
.toast.success { border-color: var(--accent3); color: var(--accent3); }
|
||||
|
||||
.links {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links a:hover { text-decoration: underline; }
|
||||
|
||||
.status {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--ink-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.status .dot {
|
||||
display: inline-block;
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent3);
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="landing">
|
||||
<h1>CTXD</h1>
|
||||
<div class="subtitle">Context Dossier</div>
|
||||
|
||||
<div class="description">
|
||||
Single source of truth for multi-harness project context.
|
||||
One canonical <code>AGENTS.md</code> per project, served to
|
||||
Claude, Hermes, Codex, Cursor, and any OAuth-capable MCP client.
|
||||
</div>
|
||||
|
||||
<div class="login-card" id="login-card">
|
||||
<h2>sign in</h2>
|
||||
<label>user id</label>
|
||||
<input type="text" id="user-id" placeholder="e.g. admin" autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')submitLogin()">
|
||||
<label>password</label>
|
||||
<input type="password" id="password" placeholder="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')submitLogin()">
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="submitLogin()">sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="/.well-known/oauth-authorization-server">OAuth discovery</a> ·
|
||||
<a href="/readonly/sse">read-only MCP</a> ·
|
||||
<a href="/write/sse">write MCP</a>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
<span class="dot"></span> connected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show' + (type ? ' ' + type : '');
|
||||
clearTimeout(window._tt);
|
||||
window._tt = setTimeout(() => el.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
const uid = document.getElementById('user-id').value.trim();
|
||||
const pw = document.getElementById('password').value;
|
||||
if (!uid || !pw) {
|
||||
showToast('user id and password required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: uid, password: pw }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('ctxd_session_token', data.token);
|
||||
showToast('signed in — loading dashboard', 'success');
|
||||
setTimeout(() => { window.location.href = '/'; }, 500);
|
||||
} else {
|
||||
let detail = '';
|
||||
try { detail = (await res.json()).error || ''; } catch (_) {}
|
||||
if (res.status === 401) {
|
||||
showToast(detail === 'invalid credentials' ? 'invalid user id or password' : ('login failed: ' + (detail || res.status)), 'error');
|
||||
} else if (res.status === 403 && detail === 'account inactive') {
|
||||
showToast('account inactive — contact an admin', 'error');
|
||||
} else {
|
||||
showToast('login failed (' + res.status + ')', 'error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('network error — check connection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already signed in (cookie-based — server will redirect to dashboard)
|
||||
// The "signed in" card is only shown if the server served the landing page
|
||||
// despite a valid cookie, which shouldn't happen. If it does, offer a redirect.
|
||||
(async () => {
|
||||
// If the server served the landing page, we're not authenticated via cookie.
|
||||
// Try localStorage token as fallback (for backward compat with old sessions).
|
||||
const token = localStorage.getItem('ctxd_session_token');
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('/auth/me', { headers: { Authorization: 'Bearer ' + token } });
|
||||
if (res.ok) {
|
||||
// Token works via Bearer but cookie wasn't set — force redirect with token in cookie
|
||||
// Re-login to get the cookie set, or just redirect (the dashboard JS uses Bearer too)
|
||||
document.getElementById('login-card').innerHTML = '<h2>signed in</h2><p style="font-size:0.75rem;color:var(--ink-dim);margin-bottom:0.75rem">You are signed in.</p><div class="actions"><button class="primary" onclick="window.location.href=\'/\'">open dashboard</button></div>';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
})();
|
||||
|
||||
// Status check
|
||||
(async () => {
|
||||
try {
|
||||
await fetch('/status');
|
||||
document.getElementById('status').innerHTML = '<span class="dot"></span> connected';
|
||||
} catch (_) {
|
||||
document.getElementById('status').innerHTML = 'disconnected';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user