fc1a2f5103
- 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
310 lines
8.1 KiB
HTML
310 lines
8.1 KiB
HTML
<!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>
|