feat(auth): add guest/admin PIN security model and hardening

This commit is contained in:
2026-04-27 10:09:53 -07:00
parent fc11b73319
commit 81319f83d4
6 changed files with 1394 additions and 145 deletions

978
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# Copilot Chat Incident Report: Context Loss After Project Context Change
Date observed: 2026-04-21
Reporter: Michael Shallop (Gramps)
Environment: VS Code on Linux, GitHub Copilot Chat extension present
## Summary
Switching/loading project context in the VS Code project window caused Copilot Chat conversational context to reset. This resulted in loss of recently generated conclusion/plan data that was intended to be implemented immediately after loading the new project.
## Impact
- Lost actionable conclusions from the active design/planning thread.
- Interrupted workflow at a critical handoff point (planning -> implementation).
- Forced reconstruction from memory instead of exact prior content.
- Increased risk of omissions and rework.
## Reproduction Steps
1. Have an active Copilot Chat conversation containing planning/conclusion details.
2. Load or switch project context in the current project window.
3. Return to Copilot Chat and continue the thread.
4. Observe that prior context is no longer available in-chat as expected.
## Expected Behavior
- Prior active conversation context should remain available, or
- The user should be prompted before context-destructive operations, and
- Recovery path should be obvious and reliable.
## Actual Behavior
- Current chat context was effectively reset.
- The previously concluded upgrade notes were not recoverable from active context.
- Local transcript/debug artifacts did not provide the full prior thread needed.
## Severity
High (workflow-breaking for planning-heavy sessions)
## User-visible Failure Mode
The user lost conclusion data that was intended for immediate implementation once the new project loaded.
## Suggested Fixes
1. Preserve active chat state across workspace/project context changes by default.
2. Show a blocking warning before any action that can drop active conversation state.
3. Add one-click export/snapshot of current conversation before context switch.
4. Improve transcript durability and discoverability for immediate recovery.
5. Add explicit session continuity indicator so users can verify state retention.
## Notes
- This incident occurred in a real implementation workflow and caused direct productivity loss.
- Regression tests should include workspace switch/load scenarios with active chat state.
## Escalation Constraint
- Current product constraints prevented the assistant from directly self-reporting this incident to the Copilot/VS Code dev team from within the chat runtime.
- User feedback to include verbatim: "it is idiotic to keep you from self-reporting issues like this."

83
docs/wiki/current-wip.md Normal file
View File

@@ -0,0 +1,83 @@
# JarvisChat Current WiP Backlog
Last updated: 2026-04-27
Owner: Gramps + Copilot
Scope: issues, bugs, security exposures, and feature enhancements.
Total identified items: 26
## Priority Definitions
- P0: Critical risk or data-loss/security exposure; do first.
- P1: High impact reliability/correctness work.
- P2: Important feature/UX improvements.
- P3: Nice-to-have polish.
## Top 10 (Urgency Order)
1. [P0] Add authentication/authorization for all write and admin endpoints.
2. [P0] Add CSRF/origin protection for browser-initiated state-changing requests.
3. [P0] Block unsafe URL schemes in rendered search-result links (e.g., javascript:).
4. [P0] Add rate limiting and request body size limits for chat/search/profile APIs.
5. [P1] Restrict settings updates to an allowlist of valid keys.
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).
7. [P1] Stop returning raw exception text to clients; use safe error envelopes.
8. [P1] Add automated tests for chat streaming, auto-search trigger, and memory command paths.
9. [P2] Implement skills/tool-call framework (MCP-style) with per-skill enable controls.
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
## Item 1 Executive Summary (Scope + Security)
- Status: Complete. Guest/admin capability split implemented with admin-only write enforcement, origin checks on state-changing requests, audit logging, and endpoint capability tests.
- Decision: JarvisChat is local-first by design. Primary mode is same-host Ollama; optional mode allows RFC1918 LAN endpoints only.
- Constraint: Public Internet AI endpoints are out of scope unless explicitly enabled in a future advanced mode.
- Risk: Even on LAN, unauthenticated write/admin endpoints permit unauthorized data tampering and deletion.
- Requirement: Add mandatory admin authentication for all POST/PUT/DELETE routes and destructive actions.
- Authentication shape (scope-locked): two capability tiers only: guest (chat-only) and admin (4-digit PIN unlock).
- Scope guardrail: Avoid full RBAC. Keep capability split minimal: conversational chat for guest, advanced/destructive actions for admin.
- Definition of done:
1. Auth required on all state-changing endpoints.
2. Destructive actions require admin authorization.
3. Endpoint configuration rejects non-local/non-RFC1918 AI backends by default.
4. Strong rate limiting + lockout controls in place for PIN attempts.
5. Security events logged for failed and successful admin actions.
## Full Backlog (Sorted by Priority)
### P0 Critical
1. Add auth for write/admin endpoints (`POST/PUT/DELETE` routes, mass delete, profile/settings changes).
2. Add CSRF or strict origin checks for browser session protection.
3. Validate/sanitize outbound href URLs before rendering in HTML (allow http/https only).
4. Add per-IP rate limiting on `/api/chat`, `/api/search`, `/api/profile`, `/api/settings`.
5. Enforce request size limits (message/profile text and JSON body) to prevent memory abuse.
### P1 High
6. Add settings key allowlist in `/api/settings` to prevent arbitrary key injection.
7. Add pagination (`limit`, `offset`) with enforced maximums for list APIs.
8. Add DB indexes and query hygiene for scalability (`messages.conversation_id`, timestamps).
9. Replace raw exception leakage to clients with generic safe error messages + server-side logs.
10. Add request/response timeout and retry policy consistency across external calls.
11. Add endpoint-level audit logging for destructive operations.
12. Add unit/integration tests for: remember/forget parsing, refusal detection, search fallback, SSE done/error shape.
13. Add conversation title sanitization and length constraints.
14. Ensure default preset semantics are correct (currently all seeded presets are marked default).
### P2 Important Features
15. Skills system: load markdown skill files with YAML frontmatter from skills directory.
16. Skills registry API: list/enable/disable skills and expose active skills to UI.
17. Inject active skill instructions into system prompt with bounded token budget.
18. Tool execution guardrails: allowlist, confirmation mode, and execution logs.
19. Heartbeat scheduler (cron/systemd timer) for daily check-ins.
20. Heartbeat endpoint for generated briefings and anomaly summaries.
21. Model info UI panel (description, updated date, best-use purpose).
22. Default model selection improvements and persistence validation.
23. Hidden model list support (exclude models from dropdown).
24. Model update action from UI (trigger controlled model pull).
### P3 Nice to Have
25. Conversation search/filter and export tooling.
26. Keyboard shortcuts, retry button, and source-link polish.
## Maintenance Rules
- Keep this file as the single source of truth.
- Update item priority/status whenever work starts or completes.
- Mirror the Top 10 summary in README and keep counts aligned.

View File

@@ -34,6 +34,31 @@ Built with FastAPI + SQLite + Jinja2. Runs on Python 3.13. No Docker required.
- **Conversation History** — SQLite-backed chat persistence with mass-delete option - **Conversation History** — SQLite-backed chat persistence with mass-delete option
- **Model Switching** — Change Ollama models on the fly - **Model Switching** — Change Ollama models on the fly
## Current WiP (Prioritized)
Canonical backlog: [docs/wiki/current-wip.md](docs/wiki/current-wip.md)
Scope boundary: local-first (same-host Ollama), optional RFC1918 LAN endpoints, no public Internet AI endpoints by default.
Total identified items: 26
Top 10 (brief):
1. P0: Add auth for write/admin endpoints
2. P0: Add CSRF/origin protection for state-changing requests
3. P0: Block unsafe URL schemes in rendered links
4. P0: Add rate limiting and request size limits
5. P1: Restrict `/api/settings` updates to allowlisted keys
6. P1: Add pagination + hard caps for list APIs
7. P1: Replace raw exception leakage with safe client errors
8. P1: Add automated tests for streaming/search/memory paths
9. P2: Implement MCP-style skills/tool-call framework
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default.
Implementation status: complete (guest session by default + admin unlock + admin-only write enforcement + origin checks + audit logging + capability tests).
## TODO ## TODO
1. ~~Verify SearXNG and Docker services persist across reboots~~ 1. ~~Verify SearXNG and Docker services persist across reboots~~
@@ -87,6 +112,9 @@ python3 -m venv venv
# Install dependencies # Install dependencies
./venv/bin/pip install fastapi uvicorn httpx psutil jinja2 python-multipart ./venv/bin/pip install fastapi uvicorn httpx psutil jinja2 python-multipart
# Set admin PIN before first startup (4 digits)
export JARVISCHAT_ADMIN_PIN=4827
# Create subdirectories # Create subdirectories
mkdir -p templates static mkdir -p templates static
@@ -96,6 +124,10 @@ mkdir -p templates static
# (copy logo.png to /opt/jarvischat/static/ — optional) # (copy logo.png to /opt/jarvischat/static/ — optional)
``` ```
WARNING: Do not use `1234` as your admin PIN unless you accept weak local security.
NOTE: First boot now requires `JARVISCHAT_ADMIN_PIN` unless you explicitly opt into insecure fallback with `JARVISCHAT_ALLOW_DEFAULT_PIN=true`.
### Upgrading from v1.4.x ### Upgrading from v1.4.x
```bash ```bash

View File

@@ -188,10 +188,36 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
.chat-container { padding:12px; } .chat-container { padding:12px; }
.input-area { padding:10px 12px; } .input-area { padding:10px 12px; }
} }
.auth-screen { position: fixed; inset: 0; width: 100%; height: 100vh; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.62); z-index: 3000; }
.auth-card { width: 100%; max-width: 360px; margin: 0 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; padding: 22px; box-shadow: 0 10px 28px rgba(0,0,0,0.35); }
.auth-title { font-family: var(--font-mono); font-size: 18px; color: var(--accent); margin-bottom: 6px; }
.auth-subtitle { font-size: 12px; color: var(--text-muted); margin-bottom: 14px; }
.auth-warning { margin-bottom: 12px; font-size: 12px; color: #ff8f8f; background: rgba(231,76,60,0.14); border: 1px solid rgba(231,76,60,0.35); border-radius: var(--radius); padding: 8px 10px; line-height: 1.4; }
.pin-input { width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--font-mono); font-size: 22px; letter-spacing: 6px; text-align: center; padding: 12px; margin-bottom: 10px; }
.pin-input:focus { outline: none; border-color: var(--accent-dim); }
.auth-btn { width: 100%; padding: 11px 14px; background: var(--accent-dim); border: none; border-radius: var(--radius); color: #fff; font-family: var(--font-mono); font-size: 13px; font-weight: 600; cursor: pointer; }
.auth-btn:hover { background: var(--accent); }
.auth-error { min-height: 18px; margin-top: 10px; font-size: 12px; color: var(--danger); text-align: center; }
.logout-btn { padding: 8px 10px; background: transparent; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); font-family: var(--font-mono); font-size: 11px; cursor: pointer; }
.logout-btn:hover { background: rgba(231,76,60,0.12); }
</style> </style>
</head> </head>
<body> <body>
<div class="auth-screen" id="authScreen">
<div class="auth-card">
<div class="auth-title">JarvisChat Unlock</div>
<div class="auth-subtitle">Enter 4-digit admin PIN to unlock advanced actions</div>
<div class="auth-warning">Security warning: PIN 1234 is weak. Use a non-trivial 4-digit PIN.</div>
<input id="pinInput" class="pin-input" type="password" inputmode="numeric" maxlength="4" autocomplete="off" />
<button id="unlockBtn" class="auth-btn" onclick="unlockWithPin()">UNLOCK</button>
<div id="authError" class="auth-error"></div>
</div>
</div>
<div id="appShell" style="display:flex; width:100%; height:100%;">
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<img class="logo" src="/static/logo.jpg" alt="JarvisChat Logo" onerror="this.style.display='none'"> <img class="logo" src="/static/logo.jpg" alt="JarvisChat Logo" onerror="this.style.display='none'">
@@ -284,6 +310,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
<button class="memory-badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">🧠 MEM ON</button> <button class="memory-badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">🧠 MEM ON</button>
<button class="search-badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">🔍 SEARCH ON</button> <button class="search-badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">🔍 SEARCH ON</button>
<button class="profile-badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE ON</button> <button class="profile-badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE ON</button>
<button class="logout-btn" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
</div> </div>
</div> </div>
<div class="chat-container" id="chatContainer"> <div class="chat-container" id="chatContainer">
@@ -309,6 +336,8 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
</div> </div>
</main> </main>
</div>
<script> <script>
let currentConvId = null; let currentConvId = null;
let isStreaming = false; let isStreaming = false;
@@ -320,8 +349,214 @@ let presets = [];
let modelContextSize = 8192; let modelContextSize = 8192;
let cachedProfile = ''; let cachedProfile = '';
let conversationHistory = []; let conversationHistory = [];
let appInitialized = false;
let heartbeatIntervalId = null;
let currentRole = 'guest';
const SESSION_KEY = 'jc_session_id';
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('pinInput').addEventListener('keydown', e => { if (e.key === 'Enter') unlockWithPin(); });
await bootstrapAuth();
});
window.addEventListener('pagehide', () => {
// Best-effort server-side revoke on tab close; session timeout is the fallback if beacon is dropped.
const sid = sessionStorage.getItem(SESSION_KEY);
if (!sid) return;
navigator.sendBeacon('/api/auth/logout', sid);
sessionStorage.removeItem(SESSION_KEY);
});
function showAuthScreen() {
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('pinInput').value = '';
document.getElementById('authError').textContent = '';
document.getElementById('pinInput').focus();
}
function hideAuthScreen() {
document.getElementById('authScreen').style.display = 'none';
}
function showMainScreen() {
document.getElementById('appShell').style.display = 'flex';
}
function applyRoleUI() {
// Guest mode keeps chat available while hiding controls that mutate system state.
const isAdmin = currentRole === 'admin';
const authBtn = document.getElementById('authActionBtn');
const settingsBtn = document.querySelector('.settings-btn');
const deleteAllBtn = document.querySelector('.delete-all-btn');
const memoryBadge = document.getElementById('memoryBadge');
const searchBadge = document.getElementById('searchBadge');
const profileBadge = document.getElementById('profileBadge');
authBtn.textContent = isAdmin ? 'LOGOUT' : 'ADMIN';
authBtn.title = isAdmin ? 'Logout admin mode' : 'Unlock admin mode';
settingsBtn.style.display = isAdmin ? 'inline-block' : 'none';
deleteAllBtn.style.display = isAdmin ? 'inline-block' : 'none';
memoryBadge.style.display = isAdmin ? 'inline-block' : 'none';
searchBadge.style.display = isAdmin ? 'inline-block' : 'none';
profileBadge.style.display = isAdmin ? 'inline-block' : 'none';
if (!isAdmin) closeSettings();
}
function requireAdminNotice() {
// Reuse the same PIN modal as a capability escalation prompt.
showAuthScreen();
document.getElementById('authError').textContent = 'Admin PIN required for this action';
}
function handleAuthExpired() {
sessionStorage.removeItem(SESSION_KEY);
currentRole = 'guest';
if (heartbeatIntervalId) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = null;
}
bootstrapAuth();
}
async function authFetch(url, options = {}) {
const sid = sessionStorage.getItem(SESSION_KEY);
const headers = { ...(options.headers || {}) };
if (sid) headers['X-Session-ID'] = sid;
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
// Session missing/expired/revoked: return to bootstrap flow and recreate guest session.
handleAuthExpired();
throw new Error('Authentication required');
}
if (response.status === 403) {
// Authenticated but insufficient capability (guest hitting admin action).
requireAdminNotice();
throw new Error('Admin PIN required');
}
return response;
}
async function createGuestSession() {
// Guest session is the default so conversational UX works without PIN friction.
const resp = await fetch('/api/auth/guest', { method: 'POST' });
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Unable to create guest session');
sessionStorage.setItem(SESSION_KEY, data.session_id);
currentRole = data.role || 'guest';
}
async function bootstrapAuth() {
const sid = sessionStorage.getItem(SESSION_KEY);
try {
if (sid) {
// Restore prior tab session when possible to avoid unnecessary re-prompts.
const resp = await fetch('/api/auth/session', {
headers: { 'X-Session-ID': sid }
});
const data = await resp.json();
if (resp.ok && data.authenticated) {
currentRole = data.role || 'guest';
} else {
sessionStorage.removeItem(SESSION_KEY);
await createGuestSession();
}
} else {
await createGuestSession();
}
showMainScreen();
hideAuthScreen();
applyRoleUI();
startHeartbeat();
await initializeMainApp();
} catch (e) {
showMainScreen();
showAuthScreen();
}
}
async function unlockWithPin() {
const pinInput = document.getElementById('pinInput');
const authError = document.getElementById('authError');
const unlockBtn = document.getElementById('unlockBtn');
const pin = (pinInput.value || '').trim();
if (!/^\d{4}$/.test(pin)) {
authError.textContent = 'PIN must be 4 digits';
return;
}
unlockBtn.disabled = true;
authError.textContent = '';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await resp.json();
if (!resp.ok) {
authError.textContent = data.detail || 'Login failed';
unlockBtn.disabled = false;
pinInput.select();
return;
}
sessionStorage.setItem(SESSION_KEY, data.session_id);
// Admin session replaces guest session in this tab only.
currentRole = data.role || 'admin';
showMainScreen();
hideAuthScreen();
applyRoleUI();
startHeartbeat();
await initializeMainApp();
} catch (e) {
authError.textContent = 'Unable to reach auth endpoint';
} finally {
unlockBtn.disabled = false;
}
}
async function logoutToGuest() {
try {
await authFetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
// Ignore; local client state cleanup still proceeds.
}
// Drop elevated session and immediately re-issue a guest token for continued chat use.
sessionStorage.removeItem(SESSION_KEY);
await createGuestSession();
currentRole = 'guest';
applyRoleUI();
hideAuthScreen();
}
function handleAuthAction() {
if (currentRole === 'admin') {
logoutToGuest();
return;
}
showAuthScreen();
}
async function sendHeartbeat() {
try {
await authFetch('/api/auth/heartbeat', { method: 'POST' });
} catch (e) {
// authFetch handles invalid session case.
}
}
function startHeartbeat() {
if (heartbeatIntervalId) clearInterval(heartbeatIntervalId);
heartbeatIntervalId = setInterval(sendHeartbeat, 30000);
}
async function initializeMainApp() {
if (appInitialized) return;
appInitialized = true;
await loadModels(); await loadModels();
await loadSettings(); await loadSettings();
await loadProfile(); await loadProfile();
@@ -336,16 +571,16 @@ document.addEventListener('DOMContentLoaded', async () => {
setInterval(updateSystemStats, 2000); setInterval(updateSystemStats, 2000);
document.getElementById('userInput').addEventListener('input', updateTokenThermometer); document.getElementById('userInput').addEventListener('input', updateTokenThermometer);
updateTokenThermometer(); updateTokenThermometer();
}); }
async function loadMemoryStats() { async function loadMemoryStats() {
try { try {
const resp = await fetch('/api/memories/stats'); const resp = await authFetch('/api/memories/stats');
const data = await resp.json(); const data = await resp.json();
document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`; document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`;
document.getElementById('memoryStatus').innerHTML = `<span class="status-dot"></span> memory: ${data.total} entries`; document.getElementById('memoryStatus').innerHTML = `<span class="status-dot"></span> memory: ${data.total} entries`;
const listResp = await fetch('/api/memories?limit=20'); const listResp = await authFetch('/api/memories?limit=20');
const listData = await listResp.json(); const listData = await listResp.json();
const container = document.getElementById('memoryList'); const container = document.getElementById('memoryList');
container.innerHTML = ''; container.innerHTML = '';
@@ -359,14 +594,15 @@ async function loadMemoryStats() {
} }
async function deleteMemory(rowid) { async function deleteMemory(rowid) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this memory?')) return; if (!confirm('Delete this memory?')) return;
await fetch(`/api/memories/${rowid}`, { method: 'DELETE' }); await authFetch(`/api/memories/${rowid}`, { method: 'DELETE' });
await loadMemoryStats(); await loadMemoryStats();
} }
async function updateSystemStats() { async function updateSystemStats() {
try { try {
const resp = await fetch('/api/stats'); const resp = await authFetch('/api/stats');
const data = await resp.json(); const data = await resp.json();
document.getElementById('cpuFill').style.width = data.cpu_percent + '%'; document.getElementById('cpuFill').style.width = data.cpu_percent + '%';
document.getElementById('cpuFill').className = 'stat-fill' + (data.cpu_percent >= 90 ? ' danger' : data.cpu_percent >= 70 ? ' warn' : ''); document.getElementById('cpuFill').className = 'stat-fill' + (data.cpu_percent >= 90 ? ' danger' : data.cpu_percent >= 70 ? ' warn' : '');
@@ -385,7 +621,7 @@ async function updateSystemStats() {
async function checkOllamaStatus() { async function checkOllamaStatus() {
try { try {
const resp = await fetch('/api/ps'); const resp = await authFetch('/api/ps');
const data = await resp.json(); const data = await resp.json();
const el = document.getElementById('ollamaStatus'); const el = document.getElementById('ollamaStatus');
const models = data.models || []; const models = data.models || [];
@@ -397,7 +633,7 @@ async function checkOllamaStatus() {
async function checkSearchStatus() { async function checkSearchStatus() {
try { try {
const resp = await fetch('/api/search/status'); const resp = await authFetch('/api/search/status');
const data = await resp.json(); const data = await resp.json();
document.getElementById('searchStatus').innerHTML = data.available ? '<span class="status-dot"></span> search: ready' : '<span class="status-dot warning"></span> search: unavailable'; document.getElementById('searchStatus').innerHTML = data.available ? '<span class="status-dot"></span> search: ready' : '<span class="status-dot warning"></span> search: unavailable';
} catch(e) { } catch(e) {
@@ -407,7 +643,7 @@ async function checkSearchStatus() {
async function loadModels() { async function loadModels() {
try { try {
const resp = await fetch('/api/models'); const resp = await authFetch('/api/models');
const data = await resp.json(); const data = await resp.json();
const select = document.getElementById('modelSelect'); const select = document.getElementById('modelSelect');
const settingSelect = document.getElementById('defaultModelSetting'); const settingSelect = document.getElementById('defaultModelSetting');
@@ -426,7 +662,7 @@ async function fetchModelContextSize() {
const model = document.getElementById('modelSelect').value; const model = document.getElementById('modelSelect').value;
if (!model) return; if (!model) return;
try { try {
const resp = await fetch('/api/show', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: model }) }); const resp = await authFetch('/api/show', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: model }) });
const data = await resp.json(); const data = await resp.json();
if (data.model_info && data.model_info['context_length']) modelContextSize = data.model_info['context_length']; if (data.model_info && data.model_info['context_length']) modelContextSize = data.model_info['context_length'];
else if (data.parameters) { const match = data.parameters.match(/num_ctx\s+(\d+)/); if (match) modelContextSize = parseInt(match[1]); } else if (data.parameters) { const match = data.parameters.match(/num_ctx\s+(\d+)/); if (match) modelContextSize = parseInt(match[1]); }
@@ -436,7 +672,7 @@ async function fetchModelContextSize() {
async function loadSettings() { async function loadSettings() {
try { try {
const resp = await fetch('/api/settings'); const resp = await authFetch('/api/settings');
const s = await resp.json(); const s = await resp.json();
profileEnabled = s.profile_enabled !== 'false'; profileEnabled = s.profile_enabled !== 'false';
searchEnabled = s.search_enabled !== 'false'; searchEnabled = s.search_enabled !== 'false';
@@ -452,17 +688,19 @@ async function loadSettings() {
} }
async function saveSettings() { async function saveSettings() {
await fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ profile_enabled: profileEnabled ? 'true' : 'false', search_enabled: searchEnabled ? 'true' : 'false', memory_enabled: memoryEnabled ? 'true' : 'false' }) }); if (currentRole !== 'admin') { requireAdminNotice(); return; }
await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ profile_enabled: profileEnabled ? 'true' : 'false', search_enabled: searchEnabled ? 'true' : 'false', memory_enabled: memoryEnabled ? 'true' : 'false' }) });
} }
async function saveDefaultModel() { async function saveDefaultModel() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const model = document.getElementById('defaultModelSetting').value; const model = document.getElementById('defaultModelSetting').value;
await fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) }); await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) });
} }
async function loadProfile() { async function loadProfile() {
try { try {
const resp = await fetch('/api/profile'); const resp = await authFetch('/api/profile');
const data = await resp.json(); const data = await resp.json();
cachedProfile = data.content || ''; cachedProfile = data.content || '';
document.getElementById('profileEditor').value = cachedProfile; document.getElementById('profileEditor').value = cachedProfile;
@@ -472,8 +710,9 @@ async function loadProfile() {
} }
async function saveProfile() { async function saveProfile() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const content = document.getElementById('profileEditor').value; const content = document.getElementById('profileEditor').value;
await fetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) }); await authFetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) });
cachedProfile = content; cachedProfile = content;
updateTokenCount(); updateTokenCount();
const btn = document.getElementById('saveProfileBtn'); const btn = document.getElementById('saveProfileBtn');
@@ -482,18 +721,19 @@ async function saveProfile() {
} }
async function resetProfile() { async function resetProfile() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Reset profile to default?')) return; if (!confirm('Reset profile to default?')) return;
try { try {
const resp = await fetch('/api/profile/default'); const resp = await authFetch('/api/profile/default');
const data = await resp.json(); const data = await resp.json();
document.getElementById('profileEditor').value = data.content; document.getElementById('profileEditor').value = data.content;
await saveProfile(); await saveProfile();
} catch(e) {} } catch(e) {}
} }
function toggleProfile() { profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); } function toggleProfile() { if (currentRole !== 'admin') { requireAdminNotice(); return; } profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
function toggleSearch() { searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); } function toggleSearch() { if (currentRole !== 'admin') { requireAdminNotice(); return; } searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
function toggleMemory() { memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); } function toggleMemory() { if (currentRole !== 'admin') { requireAdminNotice(); return; } memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
function updateProfileUI() { function updateProfileUI() {
const badge = document.getElementById('profileBadge'); const badge = document.getElementById('profileBadge');
@@ -554,7 +794,7 @@ document.getElementById('presetSelect').addEventListener('change', updateTokenTh
async function loadPresets() { async function loadPresets() {
try { try {
const resp = await fetch('/api/presets'); const resp = await authFetch('/api/presets');
presets = await resp.json(); presets = await resp.json();
renderPresetList(); renderPresetList();
renderPresetSelect(); renderPresetSelect();
@@ -581,28 +821,31 @@ function renderPresetSelect() {
} }
async function addPreset() { async function addPreset() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const name = prompt('Preset name:'); const name = prompt('Preset name:');
if (!name) return; if (!name) return;
const p = prompt('System prompt text:'); const p = prompt('System prompt text:');
if (!p) return; if (!p) return;
await fetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) }); await authFetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
await loadPresets(); await loadPresets();
} }
async function editPreset(id) { async function editPreset(id) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const preset = presets.find(p => p.id === id); const preset = presets.find(p => p.id === id);
if (!preset) return; if (!preset) return;
const name = prompt('Preset name:', preset.name); const name = prompt('Preset name:', preset.name);
if (!name) return; if (!name) return;
const p = prompt('System prompt:', preset.prompt); const p = prompt('System prompt:', preset.prompt);
if (p === null) return; if (p === null) return;
await fetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) }); await authFetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
await loadPresets(); await loadPresets();
} }
async function deletePreset(id) { async function deletePreset(id) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this preset?')) return; if (!confirm('Delete this preset?')) return;
await fetch(`/api/presets/${id}`, { method: 'DELETE' }); await authFetch(`/api/presets/${id}`, { method: 'DELETE' });
await loadPresets(); await loadPresets();
} }
@@ -613,20 +856,26 @@ function getSelectedPresetPrompt() {
return p ? p.prompt : ''; return p ? p.prompt : '';
} }
function openSettings() { document.getElementById('settingsModal').classList.add('visible'); loadProfile(); loadMemoryStats(); } function openSettings() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
document.getElementById('settingsModal').classList.add('visible');
loadProfile();
loadMemoryStats();
}
function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); } function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); }
document.getElementById('settingsModal').addEventListener('click', e => { if (e.target.id === 'settingsModal') closeSettings(); }); document.getElementById('settingsModal').addEventListener('click', e => { if (e.target.id === 'settingsModal') closeSettings(); });
async function loadConversations() { async function loadConversations() {
try { try {
const resp = await fetch('/api/conversations'); const resp = await authFetch('/api/conversations');
const convs = await resp.json(); const convs = await resp.json();
const list = document.getElementById('convList'); const list = document.getElementById('convList');
list.innerHTML = ''; list.innerHTML = '';
convs.forEach(c => { convs.forEach(c => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'conv-item' + (c.id === currentConvId ? ' active' : ''); div.className = 'conv-item' + (c.id === currentConvId ? ' active' : '');
div.innerHTML = `<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span><span class="conv-delete" onclick="event.stopPropagation();deleteConversation('${c.id}')">×</span>`; const delBtn = currentRole === 'admin' ? `<span class="conv-delete" onclick="event.stopPropagation();deleteConversation('${c.id}')">×</span>` : '';
div.innerHTML = `<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span>${delBtn}`;
list.appendChild(div); list.appendChild(div);
}); });
} catch(e) {} } catch(e) {}
@@ -634,7 +883,7 @@ async function loadConversations() {
async function loadConversation(convId) { async function loadConversation(convId) {
try { try {
const resp = await fetch(`/api/conversations/${convId}`); const resp = await authFetch(`/api/conversations/${convId}`);
const data = await resp.json(); const data = await resp.json();
currentConvId = convId; currentConvId = convId;
document.getElementById('modelSelect').value = data.conversation.model; document.getElementById('modelSelect').value = data.conversation.model;
@@ -650,15 +899,17 @@ async function loadConversation(convId) {
} }
async function deleteConversation(convId) { async function deleteConversation(convId) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this conversation?')) return; if (!confirm('Delete this conversation?')) return;
await fetch(`/api/conversations/${convId}`, { method: 'DELETE' }); await authFetch(`/api/conversations/${convId}`, { method: 'DELETE' });
if (currentConvId === convId) { currentConvId = null; showWelcome(); } if (currentConvId === convId) { currentConvId = null; showWelcome(); }
await loadConversations(); await loadConversations();
} }
async function deleteAllConversations() { async function deleteAllConversations() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete ALL conversations? This cannot be undone.')) return; if (!confirm('Delete ALL conversations? This cannot be undone.')) return;
await fetch('/api/conversations', { method: 'DELETE' }); await authFetch('/api/conversations', { method: 'DELETE' });
currentConvId = null; currentConvId = null;
conversationHistory = []; conversationHistory = [];
showWelcome(); showWelcome();
@@ -696,7 +947,7 @@ async function sendSearch() {
setStreamingState(true); setStreamingState(true);
try { try {
abortController = new AbortController(); abortController = new AbortController();
const resp = await fetch('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, query, model }), signal: abortController.signal }); const resp = await authFetch('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, query, model }), signal: abortController.signal });
const reader = resp.body.getReader(); const reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullText = ''; let fullText = '';
@@ -765,7 +1016,7 @@ async function sendMessage() {
let searchTriggered = false; let searchTriggered = false;
try { try {
abortController = new AbortController(); abortController = new AbortController();
const resp = await fetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, message, model, system_prompt: presetPrompt }), signal: abortController.signal }); const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, message, model, system_prompt: presetPrompt }), signal: abortController.signal });
const reader = resp.body.getReader(); const reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullText = ''; let fullText = '';

View File

@@ -0,0 +1,78 @@
import os
from pathlib import Path
from fastapi.testclient import TestClient
import app as app_module
def make_client(tmp_path: Path) -> TestClient:
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
app_module.DB_PATH = tmp_path / "jarvischat-test.db"
app_module.SESSIONS.clear()
app_module.PIN_ATTEMPTS.clear()
app_module.init_db()
return TestClient(app_module.app)
def test_guest_read_only_admin_write_blocked(tmp_path: Path):
with make_client(tmp_path) as client:
guest = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
assert guest.status_code == 200
sid = guest.json()["session_id"]
headers = {"X-Session-ID": sid}
read_resp = client.get("/api/memories", headers=headers)
assert read_resp.status_code == 200
write_resp = client.post(
"/api/memories",
json={"fact": "guest write should fail", "topic": "general"},
headers={**headers, "Origin": "http://testserver"},
)
assert write_resp.status_code == 403
def test_admin_can_write_and_delete_memory(tmp_path: Path):
with make_client(tmp_path) as client:
login = client.post(
"/api/auth/login",
json={"pin": "1234"},
headers={"Origin": "http://testserver"},
)
assert login.status_code == 200
sid = login.json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
create_resp = client.post(
"/api/memories",
json={"fact": "admin write ok", "topic": "general"},
headers=headers,
)
assert create_resp.status_code == 200
rowid = create_resp.json()["rowid"]
delete_resp = client.delete(f"/api/memories/{rowid}", headers=headers)
assert delete_resp.status_code == 200
def test_origin_check_blocks_cross_site_writes(tmp_path: Path):
with make_client(tmp_path) as client:
denied = client.post("/api/auth/guest", headers={"Origin": "http://evil.example"})
assert denied.status_code == 403
allowed = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
assert allowed.status_code == 200
def test_logout_revokes_session(tmp_path: Path):
with make_client(tmp_path) as client:
guest = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
sid = guest.json()["session_id"]
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
logout = client.post("/api/auth/logout", headers=headers)
assert logout.status_code == 200
after = client.get("/api/memories", headers={"X-Session-ID": sid})
assert after.status_code == 401