feat(auth): add guest/admin PIN security model and hardening
This commit is contained in:
51
docs/copilot-context-loss-incident-2026-04-21.md
Normal file
51
docs/copilot-context-loss-incident-2026-04-21.md
Normal 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
83
docs/wiki/current-wip.md
Normal 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.
|
||||
32
readme.md
32
readme.md
@@ -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
|
||||
- **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
|
||||
|
||||
1. ~~Verify SearXNG and Docker services persist across reboots~~
|
||||
@@ -87,6 +112,9 @@ python3 -m venv venv
|
||||
# Install dependencies
|
||||
./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
|
||||
mkdir -p templates static
|
||||
|
||||
@@ -96,6 +124,10 @@ mkdir -p templates static
|
||||
# (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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -188,10 +188,36 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
.chat-container { padding: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>
|
||||
</head>
|
||||
<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">
|
||||
<div class="sidebar-header">
|
||||
<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="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="logout-btn" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container" id="chatContainer">
|
||||
@@ -309,6 +336,8 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentConvId = null;
|
||||
let isStreaming = false;
|
||||
@@ -320,8 +349,214 @@ let presets = [];
|
||||
let modelContextSize = 8192;
|
||||
let cachedProfile = '';
|
||||
let conversationHistory = [];
|
||||
let appInitialized = false;
|
||||
let heartbeatIntervalId = null;
|
||||
let currentRole = 'guest';
|
||||
|
||||
const SESSION_KEY = 'jc_session_id';
|
||||
|
||||
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 loadSettings();
|
||||
await loadProfile();
|
||||
@@ -336,16 +571,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
setInterval(updateSystemStats, 2000);
|
||||
document.getElementById('userInput').addEventListener('input', updateTokenThermometer);
|
||||
updateTokenThermometer();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMemoryStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/memories/stats');
|
||||
const resp = await authFetch('/api/memories/stats');
|
||||
const data = await resp.json();
|
||||
document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`;
|
||||
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 container = document.getElementById('memoryList');
|
||||
container.innerHTML = '';
|
||||
@@ -359,14 +594,15 @@ async function loadMemoryStats() {
|
||||
}
|
||||
|
||||
async function deleteMemory(rowid) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete this memory?')) return;
|
||||
await fetch(`/api/memories/${rowid}`, { method: 'DELETE' });
|
||||
await authFetch(`/api/memories/${rowid}`, { method: 'DELETE' });
|
||||
await loadMemoryStats();
|
||||
}
|
||||
|
||||
async function updateSystemStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats');
|
||||
const resp = await authFetch('/api/stats');
|
||||
const data = await resp.json();
|
||||
document.getElementById('cpuFill').style.width = data.cpu_percent + '%';
|
||||
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() {
|
||||
try {
|
||||
const resp = await fetch('/api/ps');
|
||||
const resp = await authFetch('/api/ps');
|
||||
const data = await resp.json();
|
||||
const el = document.getElementById('ollamaStatus');
|
||||
const models = data.models || [];
|
||||
@@ -397,7 +633,7 @@ async function checkOllamaStatus() {
|
||||
|
||||
async function checkSearchStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/search/status');
|
||||
const resp = await authFetch('/api/search/status');
|
||||
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';
|
||||
} catch(e) {
|
||||
@@ -407,7 +643,7 @@ async function checkSearchStatus() {
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const resp = await fetch('/api/models');
|
||||
const resp = await authFetch('/api/models');
|
||||
const data = await resp.json();
|
||||
const select = document.getElementById('modelSelect');
|
||||
const settingSelect = document.getElementById('defaultModelSetting');
|
||||
@@ -426,7 +662,7 @@ async function fetchModelContextSize() {
|
||||
const model = document.getElementById('modelSelect').value;
|
||||
if (!model) return;
|
||||
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();
|
||||
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]); }
|
||||
@@ -436,7 +672,7 @@ async function fetchModelContextSize() {
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings');
|
||||
const resp = await authFetch('/api/settings');
|
||||
const s = await resp.json();
|
||||
profileEnabled = s.profile_enabled !== 'false';
|
||||
searchEnabled = s.search_enabled !== 'false';
|
||||
@@ -452,17 +688,19 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
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() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
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() {
|
||||
try {
|
||||
const resp = await fetch('/api/profile');
|
||||
const resp = await authFetch('/api/profile');
|
||||
const data = await resp.json();
|
||||
cachedProfile = data.content || '';
|
||||
document.getElementById('profileEditor').value = cachedProfile;
|
||||
@@ -472,8 +710,9 @@ async function loadProfile() {
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
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;
|
||||
updateTokenCount();
|
||||
const btn = document.getElementById('saveProfileBtn');
|
||||
@@ -482,18 +721,19 @@ async function saveProfile() {
|
||||
}
|
||||
|
||||
async function resetProfile() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Reset profile to default?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/profile/default');
|
||||
const resp = await authFetch('/api/profile/default');
|
||||
const data = await resp.json();
|
||||
document.getElementById('profileEditor').value = data.content;
|
||||
await saveProfile();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleProfile() { profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
|
||||
function toggleSearch() { searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
|
||||
function toggleMemory() { memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
|
||||
function toggleProfile() { if (currentRole !== 'admin') { requireAdminNotice(); return; } profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
|
||||
function toggleSearch() { if (currentRole !== 'admin') { requireAdminNotice(); return; } searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
|
||||
function toggleMemory() { if (currentRole !== 'admin') { requireAdminNotice(); return; } memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
|
||||
|
||||
function updateProfileUI() {
|
||||
const badge = document.getElementById('profileBadge');
|
||||
@@ -554,7 +794,7 @@ document.getElementById('presetSelect').addEventListener('change', updateTokenTh
|
||||
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const resp = await fetch('/api/presets');
|
||||
const resp = await authFetch('/api/presets');
|
||||
presets = await resp.json();
|
||||
renderPresetList();
|
||||
renderPresetSelect();
|
||||
@@ -581,28 +821,31 @@ function renderPresetSelect() {
|
||||
}
|
||||
|
||||
async function addPreset() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const name = prompt('Preset name:');
|
||||
if (!name) return;
|
||||
const p = prompt('System prompt text:');
|
||||
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();
|
||||
}
|
||||
|
||||
async function editPreset(id) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const preset = presets.find(p => p.id === id);
|
||||
if (!preset) return;
|
||||
const name = prompt('Preset name:', preset.name);
|
||||
if (!name) return;
|
||||
const p = prompt('System prompt:', preset.prompt);
|
||||
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();
|
||||
}
|
||||
|
||||
async function deletePreset(id) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete this preset?')) return;
|
||||
await fetch(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
await authFetch(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
await loadPresets();
|
||||
}
|
||||
|
||||
@@ -613,20 +856,26 @@ function getSelectedPresetPrompt() {
|
||||
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'); }
|
||||
document.getElementById('settingsModal').addEventListener('click', e => { if (e.target.id === 'settingsModal') closeSettings(); });
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const resp = await fetch('/api/conversations');
|
||||
const resp = await authFetch('/api/conversations');
|
||||
const convs = await resp.json();
|
||||
const list = document.getElementById('convList');
|
||||
list.innerHTML = '';
|
||||
convs.forEach(c => {
|
||||
const div = document.createElement('div');
|
||||
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);
|
||||
});
|
||||
} catch(e) {}
|
||||
@@ -634,7 +883,7 @@ async function loadConversations() {
|
||||
|
||||
async function loadConversation(convId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${convId}`);
|
||||
const resp = await authFetch(`/api/conversations/${convId}`);
|
||||
const data = await resp.json();
|
||||
currentConvId = convId;
|
||||
document.getElementById('modelSelect').value = data.conversation.model;
|
||||
@@ -650,15 +899,17 @@ async function loadConversation(convId) {
|
||||
}
|
||||
|
||||
async function deleteConversation(convId) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); 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(); }
|
||||
await loadConversations();
|
||||
}
|
||||
|
||||
async function deleteAllConversations() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); 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;
|
||||
conversationHistory = [];
|
||||
showWelcome();
|
||||
@@ -696,7 +947,7 @@ async function sendSearch() {
|
||||
setStreamingState(true);
|
||||
try {
|
||||
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 decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
@@ -765,7 +1016,7 @@ async function sendMessage() {
|
||||
let searchTriggered = false;
|
||||
try {
|
||||
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 decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
|
||||
78
tests/test_auth_capabilities.py
Normal file
78
tests/test_auth_capabilities.py
Normal 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
|
||||
Reference in New Issue
Block a user