1mod context_pack;
45mod lifecycle;
46mod recovery;
47
48use std::collections::{BTreeMap, HashMap};
49use std::convert::Infallible;
50use std::net::IpAddr;
51use std::sync::Arc;
52use std::time::{Duration, Instant};
53
54use axum::{
55 Json, Router,
56 extract::{Path, Query, Request, State},
57 http::{HeaderMap, HeaderValue, Method, StatusCode, header},
58 middleware::{self, Next},
59 response::{
60 Html, IntoResponse,
61 sse::{Event, Sse},
62 },
63 routing::{delete, get, post},
64};
65pub use memex_contracts::audit::{AuditRecommendation, AuditResult, ChunkQuality, QualityTier};
66pub use memex_contracts::progress::{
67 AuditProgress, CompactProgress, MergeProgress, ReindexProgress, RepairResult,
68 ReprocessProgress, SseEvent,
69};
70pub use memex_contracts::stats::{DatabaseStats, NamespaceStats, StorageMetrics};
71pub use memex_contracts::timeline::{TimeRange, TimelineEntry, TimelineFilter};
72use openidconnect::{
73 AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
74 PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
75 core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
76};
77use serde::{Deserialize, Serialize};
78use serde_json::json;
79use subtle::ConstantTimeEq;
80use tokio::sync::{RwLock, broadcast};
81use tower_http::cors::CorsLayer;
82use tracing::{debug, error, info, warn};
83
84use crate::diagnostics::{
85 self, BackfillHashesResult, DedupResult as DiagnosticDedupResult, KeepStrategy,
86 PurgeQualityResult, TimelineBucket, TimelineQuery,
87};
88use crate::mcp_core::{McpCore, McpTransport, dispatch_mcp_payload};
89use crate::rag::{RAGPipeline, SearchOptions, SearchResult, SliceLayer};
90use crate::search::{HybridSearchResult, SearchMode};
91use crate::storage::{ChromaDocument, SchemaMismatchWriteError, SchemaVersion};
92
93const DASHBOARD_SESSION_COOKIE: &str = "rust_memex_dashboard_session";
94const DIAGNOSTIC_APPROVAL_TTL: Duration = Duration::from_secs(300);
95
96const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
102<html lang="en">
103<head>
104 <meta charset="UTF-8">
105 <meta name="viewport" content="width=device-width, initial-scale=1.0">
106 <title>rust-memex Dashboard</title>
107 <style>
108 :root {
109 --bg: #0d1117;
110 --bg-secondary: #161b22;
111 --border: #30363d;
112 --text: #c9d1d9;
113 --text-muted: #8b949e;
114 --accent: #58a6ff;
115 --accent-muted: #388bfd;
116 --success: #3fb950;
117 --warning: #d29922;
118 --error: #f85149;
119 }
120 * { box-sizing: border-box; margin: 0; padding: 0; }
121 body {
122 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
123 background: var(--bg);
124 color: var(--text);
125 line-height: 1.5;
126 min-height: 100vh;
127 }
128 .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
129 header {
130 display: flex;
131 justify-content: space-between;
132 align-items: center;
133 padding: 16px 0;
134 border-bottom: 1px solid var(--border);
135 margin-bottom: 24px;
136 }
137 h1 { font-size: 24px; font-weight: 600; }
138 h1 span { color: var(--accent); }
139 .stats-bar {
140 display: flex;
141 gap: 24px;
142 font-size: 14px;
143 color: var(--text-muted);
144 }
145 .stats-bar strong { color: var(--text); }
146 .header-actions {
147 display: flex;
148 align-items: center;
149 gap: 12px;
150 }
151 .header-actions button {
152 padding: 9px 14px;
153 background: transparent;
154 border: 1px solid var(--border);
155 border-radius: 999px;
156 color: var(--text-muted);
157 cursor: pointer;
158 }
159 .header-actions button:hover {
160 color: var(--text);
161 border-color: var(--accent);
162 }
163
164 /* Search box */
165 .search-box {
166 display: flex;
167 gap: 12px;
168 margin-bottom: 24px;
169 }
170 .search-box input {
171 flex: 1;
172 padding: 12px 16px;
173 background: var(--bg-secondary);
174 border: 1px solid var(--border);
175 border-radius: 6px;
176 color: var(--text);
177 font-size: 16px;
178 }
179 .search-box input:focus {
180 outline: none;
181 border-color: var(--accent);
182 }
183 .search-box select {
184 padding: 12px 16px;
185 background: var(--bg-secondary);
186 border: 1px solid var(--border);
187 border-radius: 6px;
188 color: var(--text);
189 font-size: 14px;
190 min-width: 200px;
191 }
192 .search-box button {
193 padding: 12px 24px;
194 background: var(--accent);
195 border: none;
196 border-radius: 6px;
197 color: #fff;
198 font-weight: 600;
199 cursor: pointer;
200 transition: background 0.2s;
201 }
202 .search-box button:hover { background: var(--accent-muted); }
203
204 /* Layout */
205 .layout {
206 display: grid;
207 grid-template-columns: 280px 1fr;
208 gap: 24px;
209 }
210
211 /* Sidebar */
212 .sidebar {
213 background: var(--bg-secondary);
214 border: 1px solid var(--border);
215 border-radius: 8px;
216 padding: 16px;
217 height: fit-content;
218 position: sticky;
219 top: 20px;
220 }
221 .sidebar h3 {
222 font-size: 14px;
223 color: var(--text-muted);
224 margin-bottom: 12px;
225 text-transform: uppercase;
226 letter-spacing: 0.5px;
227 }
228 .namespace-list { list-style: none; }
229 .namespace-item {
230 display: flex;
231 justify-content: space-between;
232 align-items: center;
233 padding: 10px 12px;
234 border-radius: 6px;
235 cursor: pointer;
236 transition: background 0.2s;
237 }
238 .namespace-item:hover { background: var(--bg); }
239 .namespace-item.active { background: var(--accent); color: #fff; }
240 .namespace-item .name { font-weight: 500; font-size: 14px; }
241 .namespace-item .count {
242 background: var(--bg);
243 padding: 2px 8px;
244 border-radius: 12px;
245 font-size: 12px;
246 color: var(--text-muted);
247 }
248 .namespace-item.active .count { background: rgba(255,255,255,0.2); color: #fff; }
249
250 /* Main content */
251 .main { min-width: 0; }
252 .results-header {
253 display: flex;
254 justify-content: space-between;
255 align-items: center;
256 margin-bottom: 16px;
257 }
258 .results-header h2 { font-size: 18px; }
259 .results-count { color: var(--text-muted); font-size: 14px; }
260
261 /* Document cards */
262 .doc-list { display: flex; flex-direction: column; gap: 12px; }
263 .doc-card {
264 background: var(--bg-secondary);
265 border: 1px solid var(--border);
266 border-radius: 8px;
267 padding: 16px;
268 transition: border-color 0.2s;
269 }
270 .doc-card:hover { border-color: var(--accent); }
271 .doc-card.cluster-card {
272 border-left: 3px solid var(--accent);
273 }
274 .doc-header {
275 display: flex;
276 justify-content: space-between;
277 align-items: flex-start;
278 margin-bottom: 8px;
279 gap: 12px;
280 }
281 .doc-id {
282 font-family: monospace;
283 font-size: 12px;
284 color: var(--accent);
285 background: var(--bg);
286 padding: 4px 8px;
287 border-radius: 4px;
288 overflow-wrap: anywhere;
289 }
290 .doc-score {
291 font-size: 12px;
292 color: var(--success);
293 font-weight: 600;
294 }
295 .doc-text {
296 font-size: 14px;
297 line-height: 1.6;
298 color: var(--text);
299 white-space: pre-wrap;
300 max-height: 200px;
301 overflow-y: auto;
302 }
303 .doc-meta {
304 margin-top: 12px;
305 padding-top: 12px;
306 border-top: 1px solid var(--border);
307 display: flex;
308 gap: 16px;
309 flex-wrap: wrap;
310 font-size: 12px;
311 color: var(--text-muted);
312 }
313 .doc-meta .layer {
314 padding: 2px 8px;
315 background: var(--bg);
316 border-radius: 4px;
317 }
318 .doc-actions {
319 margin-top: 12px;
320 display: flex;
321 gap: 8px;
322 flex-wrap: wrap;
323 }
324 .doc-actions button {
325 padding: 6px 12px;
326 background: var(--bg);
327 border: 1px solid var(--border);
328 border-radius: 4px;
329 color: var(--text-muted);
330 font-size: 12px;
331 cursor: pointer;
332 transition: all 0.2s;
333 }
334 .doc-actions button:hover {
335 border-color: var(--accent);
336 color: var(--accent);
337 }
338
339 /* Loading state */
340 .loading {
341 text-align: center;
342 padding: 40px;
343 color: var(--text-muted);
344 }
345 .loading::after {
346 content: '';
347 display: inline-block;
348 width: 20px;
349 height: 20px;
350 border: 2px solid var(--border);
351 border-top-color: var(--accent);
352 border-radius: 50%;
353 animation: spin 1s linear infinite;
354 margin-left: 10px;
355 }
356 @keyframes spin { to { transform: rotate(360deg); } }
357
358 /* Empty state */
359 .empty-state {
360 text-align: center;
361 padding: 60px 20px;
362 color: var(--text-muted);
363 }
364 .empty-state h3 { margin-bottom: 8px; color: var(--text); }
365
366 /* Detail modal */
367 .modal-overlay {
368 display: none;
369 position: fixed;
370 inset: 0;
371 background: rgba(0,0,0,0.8);
372 z-index: 1000;
373 justify-content: center;
374 align-items: center;
375 }
376 .modal-overlay.active { display: flex; }
377 .modal {
378 background: var(--bg-secondary);
379 border: 1px solid var(--border);
380 border-radius: 12px;
381 max-width: 800px;
382 width: 90%;
383 max-height: 90vh;
384 overflow: auto;
385 padding: 24px;
386 }
387 .modal-header {
388 display: flex;
389 justify-content: space-between;
390 align-items: center;
391 margin-bottom: 16px;
392 }
393 .modal-close {
394 background: none;
395 border: none;
396 color: var(--text-muted);
397 font-size: 24px;
398 cursor: pointer;
399 }
400 .modal-close:hover { color: var(--text); }
401 .modal pre {
402 background: var(--bg);
403 padding: 16px;
404 border-radius: 8px;
405 overflow: auto;
406 font-size: 13px;
407 white-space: pre-wrap;
408 }
409
410 /* Timeline view */
411 .timeline { padding: 20px 0; }
412 .timeline-item {
413 display: flex;
414 gap: 16px;
415 padding: 12px 0;
416 border-left: 2px solid var(--border);
417 padding-left: 20px;
418 margin-left: 8px;
419 position: relative;
420 }
421 .timeline-item::before {
422 content: '';
423 position: absolute;
424 left: -6px;
425 top: 18px;
426 width: 10px;
427 height: 10px;
428 background: var(--accent);
429 border-radius: 50%;
430 }
431 .timeline-date {
432 min-width: 100px;
433 font-size: 12px;
434 color: var(--text-muted);
435 }
436
437 /* Footer */
438 footer {
439 margin-top: 40px;
440 padding: 20px 0;
441 border-top: 1px solid var(--border);
442 text-align: center;
443 color: var(--text-muted);
444 font-size: 12px;
445 }
446 </style>
447</head>
448<body>
449 <div class="container">
450 <header>
451 <h1>rmcp-<span>memex</span></h1>
452 <div class="header-actions">
453 <div class="stats-bar" id="stats-bar">
454 <span>Loading...</span>
455 </div>
456 <button type="button" onclick="logout()">Sign out</button>
457 </div>
458 </header>
459
460 <div class="search-box">
461 <input type="text" id="search-input" placeholder="Search memories..." autocomplete="off">
462 <input type="text" id="project-input" placeholder="Project filter (optional)" autocomplete="off">
463 <select id="namespace-select">
464 <option value="">All namespaces</option>
465 </select>
466 <select id="layer-select">
467 <option value="">Outer Only</option>
468 <option value="deep">All Layers</option>
469 <option value="1">Outer</option>
470 <option value="2">Middle</option>
471 <option value="3">Inner</option>
472 <option value="4">Core</option>
473 </select>
474 <button onclick="doSearch()">Search</button>
475 </div>
476
477 <div class="layout">
478 <aside class="sidebar">
479 <h3>Namespaces</h3>
480 <ul class="namespace-list" id="namespace-list">
481 <li class="loading">Loading...</li>
482 </ul>
483 </aside>
484
485 <main class="main">
486 <div class="results-header">
487 <h2 id="results-title">Recent Memories</h2>
488 <span class="results-count" id="results-count"></span>
489 </div>
490 <div class="doc-list" id="doc-list">
491 <div class="loading">Loading memories...</div>
492 </div>
493 </main>
494 </div>
495
496 <footer>
497 rust-memex v{VERSION} | Vibecrafted with AI Agents by Loctree ©2026 Loctree
498 </footer>
499 </div>
500
501 <div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
502 <div class="modal" onclick="event.stopPropagation()">
503 <div class="modal-header">
504 <h3 id="modal-title">Document Details</h3>
505 <button class="modal-close" onclick="closeModal()">×</button>
506 </div>
507 <pre id="modal-content"></pre>
508 </div>
509 </div>
510
511 <script>
512 const API = window.location.origin;
513 let currentNamespace = null;
514 let latestDiscovery = null;
515 let latestSearchClusters = [];
516
517 // Initialize
518 document.addEventListener('DOMContentLoaded', async () => {
519 await refreshDiscovery();
520 await browse(null);
521
522 // Enter key to search
523 document.getElementById('search-input').addEventListener('keypress', e => {
524 if (e.key === 'Enter') doSearch();
525 });
526 });
527
528 // Fetch with timeout helper
529 async function fetchWithTimeout(url, options = {}, timeout = 60000) {
530 const controller = new AbortController();
531 const id = setTimeout(() => controller.abort(), timeout);
532 try {
533 const response = await fetch(url, { ...options, signal: controller.signal });
534 clearTimeout(id);
535 return response;
536 } catch (e) {
537 clearTimeout(id);
538 throw e;
539 }
540 }
541
542 async function fetchDiscovery() {
543 const res = await fetchWithTimeout(`${API}/api/discovery`, {}, 30000);
544 if (!res.ok) {
545 throw new Error(`Discovery failed with ${res.status}`);
546 }
547 return res.json();
548 }
549
550 function renderStats(data) {
551 const namespaceCount = typeof data.namespace_count === 'number'
552 ? data.namespace_count
553 : Array.isArray(data.namespaces) ? data.namespaces.length : 0;
554 const namespaceValue = data.status === 'ok'
555 ? namespaceCount.toLocaleString()
556 : 'loading';
557 const totalDocuments = typeof data.total_documents === 'number'
558 ? data.total_documents.toLocaleString()
559 : '0';
560 const statusBadge = data.status === 'ok'
561 ? ''
562 : ` <span style="color:var(--warning)">(${data.hint || 'cache loading'})</span>`;
563
564 document.getElementById('stats-bar').innerHTML = `
565 <span>Status: <strong>${data.status}</strong>${statusBadge}</span>
566 <span>Namespaces: <strong>${namespaceValue}</strong></span>
567 <span>Documents: <strong>${totalDocuments}</strong></span>
568 <span>DB: <strong>${data.db_path}</strong></span>
569 `;
570 }
571
572 function renderNamespaces(data) {
573 const list = document.getElementById('namespace-list');
574 const select = document.getElementById('namespace-select');
575 const namespaces = Array.isArray(data.namespaces) ? data.namespaces : [];
576
577 select.innerHTML = '<option value="">All namespaces</option>' +
578 namespaces.map(ns => `<option value="${ns.id}">${ns.id} (${ns.count})</option>`).join('');
579 select.value = currentNamespace || '';
580
581 if (data.status !== 'ok') {
582 list.innerHTML = `
583 <li class="empty-state" style="text-align:left;padding:16px;">
584 <h3 style="color:var(--warning)">Loading namespaces...</h3>
585 <p style="margin-top:8px;font-size:13px;color:var(--text-muted)">
586 ${data.hint || 'Namespace cache is still warming up.'}
587 </p>
588 </li>`;
589 return;
590 }
591
592 if (namespaces.length === 0) {
593 list.innerHTML = '<li class="empty-state"><h3>No namespaces</h3></li>';
594 return;
595 }
596
597 list.innerHTML = namespaces.map(ns => `
598 <li class="namespace-item${currentNamespace === ns.id ? ' active' : ''}"
599 onclick="selectNamespace('${ns.id}')">
600 <span class="name">${ns.id}</span>
601 <span class="count">${ns.count.toLocaleString()}</span>
602 </li>
603 `).join('');
604 }
605
606 async function refreshDiscovery() {
607 try {
608 document.getElementById('stats-bar').innerHTML = '<span>Loading discovery...</span>';
609 latestDiscovery = await fetchDiscovery();
610 renderStats(latestDiscovery);
611 renderNamespaces(latestDiscovery);
612
613 if (latestDiscovery.status !== 'ok') {
614 setTimeout(() => refreshDiscovery(), 5000);
615 }
616 } catch (e) {
617 document.getElementById('stats-bar').innerHTML =
618 '<span style="color:var(--warning)">Discovery unavailable - check /api/discovery</span>';
619 document.getElementById('namespace-list').innerHTML =
620 '<li style="color:var(--error)">Failed to load discovery</li>';
621 }
622 }
623
624 async function selectNamespace(ns) {
625 currentNamespace = ns;
626 document.getElementById('namespace-select').value = ns || '';
627 if (latestDiscovery) {
628 renderNamespaces(latestDiscovery);
629 }
630 await browse(ns);
631 }
632
633 async function browse(namespace) {
634 const list = document.getElementById('doc-list');
635 list.innerHTML = '<div class="loading">Loading documents (large DB may be slow)...</div>';
636
637 try {
638 const ns = namespace || '';
639 const res = await fetchWithTimeout(`${API}/api/browse/${ns}?limit=50`, {}, 120000);
640 const data = await res.json();
641
642 document.getElementById('results-title').textContent =
643 namespace ? `Browsing: ${namespace}` : 'All Memories';
644 document.getElementById('results-count').textContent =
645 `${data.documents.length} documents`;
646
647 if (data.documents.length === 0) {
648 list.innerHTML = `
649 <div class="empty-state">
650 <h3>No documents found</h3>
651 <p>This namespace is empty or no data has been indexed yet.</p>
652 </div>
653 `;
654 return;
655 }
656
657 list.innerHTML = data.documents.map(doc => renderDocCard(doc)).join('');
658 } catch (e) {
659 list.innerHTML = `<div class="empty-state" style="color:var(--error)">
660 <h3>Error loading documents</h3>
661 <p>${e.message}</p>
662 </div>`;
663 }
664 }
665
666 async function doSearch() {
667 const query = document.getElementById('search-input').value.trim();
668 if (!query) {
669 await browse(currentNamespace);
670 return;
671 }
672
673 const list = document.getElementById('doc-list');
674 list.innerHTML = '<div class="loading">Searching...</div>';
675 latestSearchClusters = [];
676
677 const namespace = document.getElementById('namespace-select').value || null;
678 const project = document.getElementById('project-input').value.trim() || null;
679 const layerValue = document.getElementById('layer-select').value;
680 const body = { query, namespace, limit: 20, project };
681 if (layerValue === 'deep') {
682 body.deep = true;
683 } else if (layerValue) {
684 body.layer = Number(layerValue);
685 }
686
687 try {
688 const res = await fetch(`${API}/search`, {
689 method: 'POST',
690 headers: { 'Content-Type': 'application/json' },
691 body: JSON.stringify(body)
692 });
693 const data = await res.json();
694
695 document.getElementById('results-title').textContent = `Search: "${query}"`;
696 const clusterCount = Array.isArray(data.clusters) ? data.clusters.length : 0;
697 const duplicateCount = typeof data.duplicate_count === 'number' ? data.duplicate_count : 0;
698 document.getElementById('results-count').textContent =
699 `${clusterCount} clusters, ${duplicateCount} hidden duplicates in ${data.elapsed_ms}ms`;
700
701 if (data.results.length === 0) {
702 list.innerHTML = `
703 <div class="empty-state">
704 <h3>No results found</h3>
705 <p>Try a different query or search all namespaces.</p>
706 </div>
707 `;
708 return;
709 }
710
711 latestSearchClusters = Array.isArray(data.clusters) ? data.clusters : [];
712 list.innerHTML = latestSearchClusters.length > 0
713 ? latestSearchClusters.map((cluster, index) => renderClusterCard(cluster, index)).join('')
714 : data.results.map(doc => renderDocCard(doc, true)).join('');
715 } catch (e) {
716 list.innerHTML = `<div class="empty-state" style="color:var(--error)">
717 <h3>Search failed</h3>
718 <p>${e.message}</p>
719 </div>`;
720 }
721 }
722
723 function renderClusterCard(cluster, index) {
724 const doc = cluster.representative;
725 const text = doc.text || '';
726 const truncated = text.length > 650 ? text.slice(0, 650) + '...' : text;
727 const layer = doc.layer || 'flat';
728 const label = cluster.source_path || cluster.session_id || doc.id;
729
730 return `
731 <div class="doc-card cluster-card">
732 <div class="doc-header">
733 <span class="doc-id">${escapeHtml(label)}</span>
734 <span class="doc-score">Score: ${doc.score.toFixed(3)}</span>
735 </div>
736 <div class="doc-text">${escapeHtml(truncated)}</div>
737 <div class="doc-meta">
738 <span>Namespace: <strong>${escapeHtml(doc.namespace)}</strong></span>
739 <span>Grouped by: <strong>${escapeHtml(cluster.group_by)}</strong></span>
740 <span>Evidence: <strong>${cluster.evidence.length}</strong></span>
741 <span>Hidden duplicates: <strong>${cluster.hidden_duplicate_count}</strong></span>
742 <span class="layer">${escapeHtml(layer)}</span>
743 </div>
744 <div class="doc-actions">
745 <button onclick="openContextPack(${index}, 'full', true)">Context Pack</button>
746 <button onclick="openContextPack(${index}, 'decisions', false)">Decisions</button>
747 <button onclick="showRawEvidence(${index})">Raw Evidence</button>
748 <button onclick="showClusterDetails(${index})">Cluster JSON</button>
749 ${doc.can_expand ? `<button onclick="expand('${doc.namespace}', '${doc.id}')">Expand â–¼</button>` : ''}
750 ${doc.can_drill_up ? `<button onclick="drillUp('${doc.namespace}', '${doc.id}')">Parent â–²</button>` : ''}
751 </div>
752 </div>
753 `;
754 }
755
756 function renderDocCard(doc, showScore = false) {
757 const text = doc.text || '';
758 const truncated = text.length > 500 ? text.slice(0, 500) + '...' : text;
759 const layer = doc.layer || 'flat';
760
761 return `
762 <div class="doc-card">
763 <div class="doc-header">
764 <span class="doc-id">${doc.id}</span>
765 ${showScore ? `<span class="doc-score">Score: ${doc.score.toFixed(3)}</span>` : ''}
766 </div>
767 <div class="doc-text">${escapeHtml(truncated)}</div>
768 <div class="doc-meta">
769 <span>Namespace: <strong>${doc.namespace}</strong></span>
770 <span class="layer">${layer}</span>
771 ${doc.can_expand ? '<span style="color:var(--accent)">â–¼ Has children</span>' : ''}
772 ${doc.can_drill_up ? '<span style="color:var(--warning)">â–² Has parent</span>' : ''}
773 </div>
774 <div class="doc-actions">
775 <button onclick='showDetails(${JSON.stringify(doc).replace(/'/g, "'")})'>Details</button>
776 ${doc.can_expand ? `<button onclick="expand('${doc.namespace}', '${doc.id}')">Expand â–¼</button>` : ''}
777 ${doc.can_drill_up ? `<button onclick="drillUp('${doc.namespace}', '${doc.id}')">Parent â–²</button>` : ''}
778 </div>
779 </div>
780 `;
781 }
782
783 async function openContextPack(index, view = 'full', showRawEvidence = true) {
784 const cluster = latestSearchClusters[index];
785 if (!cluster) return;
786 const ids = cluster.evidence.map(item => item.id);
787 const namespace = cluster.representative.namespace;
788 document.getElementById('modal-title').textContent =
789 view === 'decisions' ? 'Decision Context Pack' : 'Context Pack';
790 document.getElementById('modal-content').textContent = 'Building context pack...';
791 document.getElementById('modal-overlay').classList.add('active');
792
793 try {
794 const res = await fetch(`${API}/api/context-pack`, {
795 method: 'POST',
796 headers: { 'Content-Type': 'application/json' },
797 body: JSON.stringify({
798 namespace,
799 ids,
800 view,
801 show_decisions_only: view === 'decisions',
802 show_raw_evidence: showRawEvidence,
803 max_evidence_per_cluster: 8,
804 max_source_chunks: 240
805 })
806 });
807 const data = await res.json();
808 if (!res.ok) throw new Error(typeof data === 'string' ? data : JSON.stringify(data));
809 document.getElementById('modal-content').textContent = data.markdown || JSON.stringify(data, null, 2);
810 } catch (e) {
811 document.getElementById('modal-content').textContent = `Context pack failed: ${e.message}`;
812 }
813 }
814
815 function showRawEvidence(index) {
816 const cluster = latestSearchClusters[index];
817 if (!cluster) return;
818 document.getElementById('modal-title').textContent = 'Raw Evidence';
819 document.getElementById('modal-content').textContent = JSON.stringify(cluster.evidence, null, 2);
820 document.getElementById('modal-overlay').classList.add('active');
821 }
822
823 function showClusterDetails(index) {
824 const cluster = latestSearchClusters[index];
825 if (!cluster) return;
826 document.getElementById('modal-title').textContent = 'Cluster JSON';
827 document.getElementById('modal-content').textContent = JSON.stringify(cluster, null, 2);
828 document.getElementById('modal-overlay').classList.add('active');
829 }
830
831 function escapeHtml(text) {
832 const div = document.createElement('div');
833 div.textContent = text;
834 return div.innerHTML;
835 }
836
837 async function expand(ns, id) {
838 const list = document.getElementById('doc-list');
839 const oldContent = list.innerHTML;
840 list.innerHTML = '<div class="loading">Expanding...</div>';
841
842 try {
843 const res = await fetch(`${API}/expand/${ns}/${id}`);
844 const data = await res.json();
845
846 document.getElementById('results-title').textContent = `Children of: ${id}`;
847 document.getElementById('results-count').textContent = `${data.count} children`;
848
849 if (data.children.length === 0) {
850 list.innerHTML = `<div class="empty-state"><h3>No children</h3></div>`;
851 return;
852 }
853
854 list.innerHTML = data.children.map(doc => renderDocCard(doc)).join('');
855 } catch (e) {
856 list.innerHTML = oldContent;
857 alert('Failed to expand: ' + e.message);
858 }
859 }
860
861 async function drillUp(ns, id) {
862 const list = document.getElementById('doc-list');
863 const oldContent = list.innerHTML;
864 list.innerHTML = '<div class="loading">Finding parent...</div>';
865
866 try {
867 const res = await fetch(`${API}/parent/${ns}/${id}`);
868 const data = await res.json();
869
870 document.getElementById('results-title').textContent = `Parent of: ${id}`;
871 document.getElementById('results-count').textContent = '1 document';
872
873 list.innerHTML = renderDocCard(data.parent);
874 } catch (e) {
875 list.innerHTML = oldContent;
876 alert('Failed to find parent: ' + e.message);
877 }
878 }
879
880 function showDetails(doc) {
881 document.getElementById('modal-title').textContent = `Document: ${doc.id}`;
882 document.getElementById('modal-content').textContent = JSON.stringify(doc, null, 2);
883 document.getElementById('modal-overlay').classList.add('active');
884 }
885
886 function logout() {
887 try {
888 window.localStorage.removeItem(AUTH_STORAGE_KEY);
889 } catch (_) {}
890 window.location.assign(`${API}/auth/logout`);
891 }
892
893 function closeModal(event) {
894 if (!event || event.target.classList.contains('modal-overlay')) {
895 document.getElementById('modal-overlay').classList.remove('active');
896 }
897 }
898
899 // Close modal with Escape key
900 document.addEventListener('keydown', e => {
901 if (e.key === 'Escape') closeModal();
902 });
903 </script>
904</body>
905</html>"##;
906
907fn get_dashboard_html() -> String {
909 DASHBOARD_HTML.replace("{VERSION}", env!("CARGO_PKG_VERSION"))
910}
911
912#[derive(Debug, Clone, Serialize)]
918pub struct NamespaceInfo {
919 pub name: String,
920 pub count: usize,
921}
922
923#[derive(Debug, Serialize)]
925pub struct NamespacesResponse {
926 pub namespaces: Vec<NamespaceInfo>,
927 pub total: usize,
928}
929
930#[derive(Debug, Serialize)]
932pub struct OverviewResponse {
933 pub namespace_count: usize,
934 pub total_documents: usize,
935 pub db_path: String,
936 pub embedding_provider: String,
937}
938
939#[derive(Debug, Clone, Serialize)]
941pub struct DiscoveryNamespaceInfo {
942 pub id: String,
943 pub count: usize,
944 #[serde(skip_serializing_if = "Option::is_none")]
945 pub last_indexed_at: Option<String>,
946}
947
948#[derive(Debug, Clone, Serialize)]
950pub struct DiscoveryResponse {
951 pub status: String,
952 pub hint: String,
953 pub version: String,
954 pub db_path: String,
955 pub embedding_provider: String,
956 pub total_documents: usize,
957 pub namespace_count: usize,
958 pub namespaces: Vec<DiscoveryNamespaceInfo>,
959}
960
961#[derive(Debug, Deserialize)]
963pub struct BrowseParams {
964 #[serde(default = "default_browse_limit")]
965 pub limit: usize,
966 #[serde(default)]
967 pub offset: usize,
968}
969
970fn default_browse_limit() -> usize {
971 50
972}
973
974#[derive(Debug, Serialize)]
976pub struct BrowseResponse {
977 pub namespace: Option<String>,
978 pub documents: Vec<SearchResultJson>,
979 pub count: usize,
980 pub offset: usize,
981}
982
983pub struct McpSession {
985 pub id: String,
987 pub tx: broadcast::Sender<serde_json::Value>,
989 pub created: std::time::Instant,
991}
992
993pub struct McpSessionManager {
995 sessions: RwLock<HashMap<String, Arc<McpSession>>>,
996}
997
998impl McpSessionManager {
999 pub fn new() -> Self {
1000 Self {
1001 sessions: RwLock::new(HashMap::new()),
1002 }
1003 }
1004
1005 pub async fn create_session(&self) -> (String, broadcast::Receiver<serde_json::Value>) {
1007 let id = uuid::Uuid::new_v4().to_string();
1008 let (tx, rx) = broadcast::channel(64);
1009 let session = Arc::new(McpSession {
1010 id: id.clone(),
1011 tx,
1012 created: std::time::Instant::now(),
1013 });
1014 self.sessions.write().await.insert(id.clone(), session);
1015 (id, rx)
1016 }
1017
1018 pub async fn get_session(&self, id: &str) -> Option<Arc<McpSession>> {
1020 self.sessions.read().await.get(id).cloned()
1021 }
1022
1023 pub async fn remove_session(&self, id: &str) {
1025 self.sessions.write().await.remove(id);
1026 }
1027
1028 pub async fn cleanup_old_sessions(&self) {
1030 let mut sessions = self.sessions.write().await;
1031 sessions.retain(|_, s| s.created.elapsed() < Duration::from_secs(3600));
1032 }
1033}
1034
1035impl Default for McpSessionManager {
1036 fn default() -> Self {
1037 Self::new()
1038 }
1039}
1040
1041#[derive(Clone)]
1043pub struct HttpState {
1044 pub rag: Arc<RAGPipeline>,
1045 pub mcp_core: Arc<McpCore>,
1047 pub mcp_sessions: Arc<McpSessionManager>,
1049 pub mcp_base_url: Arc<RwLock<String>>,
1051 pub cached_namespaces: Arc<RwLock<Option<Vec<NamespaceInfo>>>>,
1053 pub namespace_activity: Arc<RwLock<HashMap<String, String>>>,
1055 pub last_successful_append_at: Arc<RwLock<Option<String>>>,
1057 pub diagnostic_dry_run_approvals: Arc<RwLock<HashMap<String, Instant>>>,
1059 pub auth_token: Option<String>,
1061 pub auth_mode: AuthMode,
1063 pub allow_query_token: bool,
1065 pub auth_manager: Option<Arc<crate::auth::AuthManager>>,
1067 dashboard_oidc: Option<Arc<DashboardOidcRuntime>>,
1069}
1070
1071impl HttpState {
1072 pub fn new(rag: Arc<RAGPipeline>, mcp_core: Arc<McpCore>) -> Self {
1073 Self {
1074 rag,
1075 mcp_core,
1076 mcp_sessions: Arc::new(McpSessionManager::new()),
1077 mcp_base_url: Arc::new(RwLock::new("http://127.0.0.1:0/mcp/messages/".to_string())),
1078 cached_namespaces: Arc::new(RwLock::new(None)),
1079 namespace_activity: Arc::new(RwLock::new(HashMap::new())),
1080 last_successful_append_at: Arc::new(RwLock::new(None)),
1081 diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())),
1082 auth_token: None,
1083 auth_mode: AuthMode::MutatingOnly,
1084 allow_query_token: false,
1085 auth_manager: None,
1086 dashboard_oidc: None,
1087 }
1088 }
1089}
1090
1091fn validate_threshold(threshold: u8) -> Result<(), (StatusCode, String)> {
1092 if threshold > 100 {
1093 return Err((
1094 StatusCode::BAD_REQUEST,
1095 "threshold must be between 0 and 100".to_string(),
1096 ));
1097 }
1098 Ok(())
1099}
1100
1101fn internal_error(error: anyhow::Error) -> (StatusCode, String) {
1102 (StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
1103}
1104
1105fn diagnostic_approval_key(
1106 operation: &str,
1107 namespace: Option<&str>,
1108 threshold: Option<u8>,
1109) -> String {
1110 let namespace = namespace.unwrap_or("*");
1111 let threshold = threshold
1112 .map(|value| value.to_string())
1113 .unwrap_or_else(|| "-".to_string());
1114 format!("{operation}:{namespace}:{threshold}")
1115}
1116
1117async fn record_diagnostic_dry_run(state: &HttpState, key: String) {
1118 let now = Instant::now();
1119 let mut approvals = state.diagnostic_dry_run_approvals.write().await;
1120 approvals.retain(|_, recorded_at| now.duration_since(*recorded_at) <= DIAGNOSTIC_APPROVAL_TTL);
1121 approvals.insert(key, now);
1122}
1123
1124async fn consume_diagnostic_dry_run(state: &HttpState, key: &str) -> bool {
1125 let now = Instant::now();
1126 let mut approvals = state.diagnostic_dry_run_approvals.write().await;
1127 approvals.retain(|_, recorded_at| now.duration_since(*recorded_at) <= DIAGNOSTIC_APPROVAL_TTL);
1128 approvals.remove(key).is_some()
1129}
1130
1131async fn ensure_destructive_diagnostic_allowed(
1132 state: &HttpState,
1133 key: String,
1134 confirm: bool,
1135 allow_single_step: bool,
1136) -> Result<(), (StatusCode, String)> {
1137 if !confirm {
1138 return Err((
1139 StatusCode::BAD_REQUEST,
1140 "destructive execution requires confirm=true".to_string(),
1141 ));
1142 }
1143
1144 if allow_single_step {
1145 return Ok(());
1146 }
1147
1148 if consume_diagnostic_dry_run(state, &key).await {
1149 return Ok(());
1150 }
1151
1152 Err((
1153 StatusCode::CONFLICT,
1154 "destructive execution requires a preceding matching dry_run=true call or allow_single_step=true".to_string(),
1155 ))
1156}
1157
1158#[derive(Debug, Clone)]
1159pub struct DashboardOidcConfig {
1160 pub issuer_url: String,
1161 pub client_id: String,
1162 pub client_secret: Option<String>,
1163 pub public_base_url: Option<String>,
1164 pub scopes: Vec<String>,
1165 pub server_port: u16,
1166}
1167
1168#[derive(Debug, Clone)]
1169struct ResolvedDashboardOidcConfig {
1170 issuer_url: String,
1171 client_id: String,
1172 client_secret: Option<String>,
1173 public_base_url: String,
1174 redirect_url: String,
1175 scopes: Vec<String>,
1176 secure_cookie: bool,
1177}
1178
1179#[derive(Debug)]
1180struct PendingDashboardLogin {
1181 pkce_verifier: PkceCodeVerifier,
1182 nonce: Nonce,
1183 created_at: Instant,
1184}
1185
1186#[derive(Debug, Clone)]
1187struct DashboardSession {
1188 subject: String,
1189 created_at: Instant,
1190}
1191
1192#[derive(Clone)]
1193struct DashboardOidcRuntime {
1194 config: ResolvedDashboardOidcConfig,
1195 pending_logins: Arc<RwLock<HashMap<String, PendingDashboardLogin>>>,
1196 sessions: Arc<RwLock<HashMap<String, DashboardSession>>>,
1197}
1198
1199impl DashboardOidcRuntime {
1200 fn new(config: ResolvedDashboardOidcConfig) -> Self {
1201 Self {
1202 config,
1203 pending_logins: Arc::new(RwLock::new(HashMap::new())),
1204 sessions: Arc::new(RwLock::new(HashMap::new())),
1205 }
1206 }
1207
1208 async fn begin_login(&self) -> anyhow::Result<String> {
1209 let issuer_url = IssuerUrl::new(self.config.issuer_url.clone())?;
1210 let redirect_url = RedirectUrl::new(self.config.redirect_url.clone())?;
1211 let http_client = reqwest::Client::builder()
1212 .redirect(reqwest::redirect::Policy::none())
1213 .build()?;
1214 let provider_metadata =
1215 CoreProviderMetadata::discover_async(issuer_url, &http_client).await?;
1216 let client = CoreClient::from_provider_metadata(
1217 provider_metadata,
1218 ClientId::new(self.config.client_id.clone()),
1219 self.config.client_secret.clone().map(ClientSecret::new),
1220 )
1221 .set_redirect_uri(redirect_url);
1222 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
1223 let csrf = CsrfToken::new(uuid::Uuid::new_v4().to_string());
1224 let nonce = Nonce::new(uuid::Uuid::new_v4().to_string());
1225 let csrf_for_request = csrf.clone();
1226 let nonce_for_request = nonce.clone();
1227
1228 let mut auth_request = client.authorize_url(
1229 CoreAuthenticationFlow::AuthorizationCode,
1230 move || csrf_for_request.clone(),
1231 move || nonce_for_request.clone(),
1232 );
1233 for scope in &self.config.scopes {
1234 auth_request = auth_request.add_scope(Scope::new(scope.clone()));
1235 }
1236 let auth_request = auth_request.set_pkce_challenge(pkce_challenge);
1237 let (auth_url, _, _) = auth_request.url();
1238
1239 self.pending_logins.write().await.insert(
1240 csrf.secret().clone(),
1241 PendingDashboardLogin {
1242 pkce_verifier,
1243 nonce,
1244 created_at: Instant::now(),
1245 },
1246 );
1247
1248 Ok(auth_url.to_string())
1249 }
1250
1251 async fn complete_login(&self, code: String, state: String) -> anyhow::Result<String> {
1252 let pending = self
1253 .pending_logins
1254 .write()
1255 .await
1256 .remove(&state)
1257 .ok_or_else(|| anyhow::anyhow!("OIDC callback state mismatch or expired"))?;
1258 let issuer_url = IssuerUrl::new(self.config.issuer_url.clone())?;
1259 let redirect_url = RedirectUrl::new(self.config.redirect_url.clone())?;
1260 let http_client = reqwest::Client::builder()
1261 .redirect(reqwest::redirect::Policy::none())
1262 .build()?;
1263 let provider_metadata =
1264 CoreProviderMetadata::discover_async(issuer_url, &http_client).await?;
1265 let client = CoreClient::from_provider_metadata(
1266 provider_metadata,
1267 ClientId::new(self.config.client_id.clone()),
1268 self.config.client_secret.clone().map(ClientSecret::new),
1269 )
1270 .set_redirect_uri(redirect_url);
1271 let token_response = client
1272 .exchange_code(AuthorizationCode::new(code))?
1273 .set_pkce_verifier(pending.pkce_verifier)
1274 .request_async(&http_client)
1275 .await?;
1276 let id_token = token_response
1277 .id_token()
1278 .ok_or_else(|| anyhow::anyhow!("OIDC provider did not return an ID token"))?;
1279 let claims = id_token.claims(&client.id_token_verifier(), &pending.nonce)?;
1280
1281 let session_id = uuid::Uuid::new_v4().to_string();
1282 self.sessions.write().await.insert(
1283 session_id.clone(),
1284 DashboardSession {
1285 subject: claims.subject().to_string(),
1286 created_at: Instant::now(),
1287 },
1288 );
1289 Ok(session_id)
1290 }
1291
1292 async fn has_session(&self, session_id: &str) -> bool {
1293 self.sessions
1294 .read()
1295 .await
1296 .get(session_id)
1297 .map(|session| {
1298 let _ = session.subject.as_str();
1299 session.created_at.elapsed() < Duration::from_secs(12 * 60 * 60)
1300 })
1301 .unwrap_or(false)
1302 }
1303
1304 async fn remove_session(&self, session_id: &str) {
1305 self.sessions.write().await.remove(session_id);
1306 }
1307
1308 async fn cleanup(&self) {
1309 self.pending_logins
1310 .write()
1311 .await
1312 .retain(|_, login| login.created_at.elapsed() < Duration::from_secs(15 * 60));
1313 self.sessions
1314 .write()
1315 .await
1316 .retain(|_, session| session.created_at.elapsed() < Duration::from_secs(12 * 60 * 60));
1317 }
1318}
1319
1320#[derive(Debug, Deserialize)]
1322pub struct SearchRequest {
1323 pub query: String,
1324 #[serde(default)]
1325 pub namespace: Option<String>,
1326 #[serde(default = "default_limit", alias = "k")]
1327 pub limit: usize,
1328 #[serde(default)]
1330 pub layer: Option<u8>,
1331 #[serde(default)]
1332 pub deep: bool,
1333 #[serde(default)]
1334 pub project: Option<String>,
1335 #[serde(default = "default_mode")]
1336 pub mode: String,
1337}
1338
1339fn default_limit() -> usize {
1340 10
1341}
1342
1343#[derive(Debug, Clone, Serialize)]
1345pub struct SearchResultJson {
1346 pub id: String,
1347 pub namespace: String,
1348 pub text: String,
1349 pub score: f32,
1350 pub metadata: serde_json::Value,
1351 #[serde(skip_serializing_if = "Option::is_none")]
1352 pub layer: Option<String>,
1353 #[serde(skip_serializing_if = "Option::is_none")]
1354 pub parent_id: Option<String>,
1355 #[serde(skip_serializing_if = "Vec::is_empty")]
1356 pub children_ids: Vec<String>,
1357 #[serde(skip_serializing_if = "Vec::is_empty")]
1358 pub keywords: Vec<String>,
1359 pub can_expand: bool,
1361 pub can_drill_up: bool,
1363}
1364
1365impl From<SearchResult> for SearchResultJson {
1366 fn from(r: SearchResult) -> Self {
1367 let can_expand = r.can_expand();
1368 let can_drill_up = r.can_drill_up();
1369 Self {
1370 id: r.id,
1371 namespace: r.namespace,
1372 text: r.text,
1373 score: r.score,
1374 metadata: r.metadata,
1375 layer: r.layer.map(|l| l.name().to_string()),
1376 parent_id: r.parent_id,
1377 children_ids: r.children_ids,
1378 keywords: r.keywords,
1379 can_expand,
1380 can_drill_up,
1381 }
1382 }
1383}
1384
1385impl From<HybridSearchResult> for SearchResultJson {
1386 fn from(result: HybridSearchResult) -> Self {
1387 let can_expand = !result.children_ids.is_empty();
1388 let can_drill_up = result.parent_id.is_some();
1389
1390 Self {
1391 id: result.id,
1392 namespace: result.namespace,
1393 text: result.document,
1394 score: result.combined_score,
1395 metadata: result.metadata,
1396 layer: result.layer.map(|layer| layer.name().to_string()),
1397 parent_id: result.parent_id,
1398 children_ids: result.children_ids,
1399 keywords: result.keywords,
1400 can_expand,
1401 can_drill_up,
1402 }
1403 }
1404}
1405
1406impl From<ChromaDocument> for SearchResultJson {
1407 fn from(doc: ChromaDocument) -> Self {
1408 let can_expand = !doc.children_ids.is_empty();
1409 let can_drill_up = doc.parent_id.is_some();
1410 let layer = doc.slice_layer().map(|layer| layer.name().to_string());
1411
1412 Self {
1413 id: doc.id,
1414 namespace: doc.namespace,
1415 text: doc.document,
1416 score: 0.0,
1417 metadata: doc.metadata,
1418 layer,
1419 parent_id: doc.parent_id,
1420 children_ids: doc.children_ids,
1421 keywords: doc.keywords,
1422 can_expand,
1423 can_drill_up,
1424 }
1425 }
1426}
1427
1428#[derive(Debug, Serialize)]
1430pub struct SearchResponse {
1431 pub results: Vec<SearchResultJson>,
1432 pub clusters: Vec<context_pack::SearchClusterJson>,
1433 pub duplicate_count: usize,
1434 pub query: String,
1435 pub namespace: Option<String>,
1436 pub elapsed_ms: u64,
1437 pub count: usize,
1438}
1439
1440#[derive(Debug, Deserialize)]
1442pub struct UpsertRequest {
1443 pub namespace: String,
1444 pub id: String,
1445 pub content: String,
1446 #[serde(default)]
1447 pub metadata: Option<serde_json::Value>,
1448}
1449
1450#[derive(Debug, Deserialize)]
1452pub struct IndexRequest {
1453 pub namespace: String,
1454 pub content: String,
1455 #[serde(default = "default_slice_mode")]
1457 pub slice_mode: String,
1458}
1459
1460fn default_slice_mode() -> String {
1461 "flat".to_string()
1462}
1463
1464#[derive(Debug, Deserialize)]
1466pub struct SseSearchParams {
1467 pub query: String,
1468 #[serde(default)]
1469 pub namespace: Option<String>,
1470 #[serde(default = "default_limit", alias = "k")]
1471 pub limit: usize,
1472 #[serde(default)]
1473 pub deep: bool,
1474 #[serde(default)]
1475 pub layer: Option<u8>,
1476 #[serde(default)]
1477 pub project: Option<String>,
1478 #[serde(default = "default_mode")]
1479 pub mode: String,
1480}
1481
1482#[derive(Debug, Deserialize)]
1484pub struct CrossSearchRequest {
1485 pub query: String,
1486 #[serde(default = "default_cross_limit")]
1488 pub limit: usize,
1489 #[serde(default = "default_total_limit")]
1491 pub total_limit: usize,
1492 #[serde(default = "default_mode")]
1494 pub mode: String,
1495}
1496
1497fn default_cross_limit() -> usize {
1498 5
1499}
1500
1501fn default_total_limit() -> usize {
1502 20
1503}
1504
1505fn default_mode() -> String {
1506 "hybrid".to_string()
1507}
1508
1509fn default_quality_threshold() -> u8 {
1510 90
1511}
1512
1513fn default_timeline_bucket() -> String {
1514 "day".to_string()
1515}
1516
1517fn default_dry_run() -> bool {
1518 true
1519}
1520
1521#[derive(Debug, Deserialize)]
1522pub struct AuditParams {
1523 pub ns: Option<String>,
1524 #[serde(default = "default_quality_threshold")]
1525 pub threshold: u8,
1526}
1527
1528#[derive(Debug, Deserialize)]
1529pub struct TimelineParams {
1530 pub ns: Option<String>,
1531 #[serde(default)]
1532 pub since: Option<String>,
1533 #[serde(default)]
1534 pub until: Option<String>,
1535 #[serde(default = "default_timeline_bucket")]
1536 pub bucket: String,
1537}
1538
1539#[derive(Debug, Deserialize)]
1540pub struct DedupParams {
1541 #[serde(default)]
1542 pub ns: Option<String>,
1543 #[serde(default)]
1544 pub execute: bool,
1545 #[serde(default, alias = "groupBy", alias = "group-by")]
1549 pub group_by: Option<String>,
1550}
1551
1552#[derive(Debug, Deserialize, Default)]
1553pub struct DedupRequest {
1554 #[serde(default)]
1555 pub confirm: bool,
1556 #[serde(default)]
1557 pub allow_single_step: bool,
1558}
1559
1560#[derive(Debug, Deserialize)]
1561pub struct PurgeQualityRequest {
1562 #[serde(default)]
1563 pub namespace: Option<String>,
1564 #[serde(default = "default_quality_threshold")]
1565 pub threshold: u8,
1566 #[serde(default)]
1567 pub confirm: bool,
1568 #[serde(default = "default_dry_run")]
1569 pub dry_run: bool,
1570 #[serde(default)]
1571 pub allow_single_step: bool,
1572}
1573
1574#[derive(Debug, Serialize)]
1575pub struct DedupResponse {
1576 pub namespace: Option<String>,
1577 pub execute: bool,
1578 pub dry_run: bool,
1579 pub result: DiagnosticDedupResult,
1580}
1581
1582#[derive(Debug, Deserialize, Default)]
1583pub struct BackfillHashesParams {
1584 #[serde(default)]
1586 pub ns: Option<String>,
1587 #[serde(default)]
1589 pub execute: bool,
1590}
1591
1592#[derive(Debug, Deserialize, Default)]
1593pub struct BackfillHashesRequest {
1594 #[serde(default)]
1595 pub confirm: bool,
1596 #[serde(default)]
1597 pub allow_single_step: bool,
1598}
1599
1600#[derive(Debug, Serialize)]
1601pub struct BackfillHashesResponse {
1602 pub namespace: Option<String>,
1603 pub execute: bool,
1604 pub dry_run: bool,
1605 pub result: BackfillHashesResult,
1606}
1607
1608fn http_search_mode(mode: &str) -> SearchMode {
1609 match mode {
1610 "vector" => SearchMode::Vector,
1611 "keyword" | "bm25" => SearchMode::Keyword,
1612 _ => SearchMode::Hybrid,
1613 }
1614}
1615
1616async fn search_results_with_mode(
1617 state: &HttpState,
1618 namespace: Option<&str>,
1619 query: &str,
1620 limit: usize,
1621 mode: SearchMode,
1622 options: SearchOptions,
1623) -> anyhow::Result<Vec<SearchResultJson>> {
1624 if mode != SearchMode::Vector
1625 && let Some(hybrid_searcher) = state.mcp_core.hybrid_searcher()
1626 {
1627 let query_embedding = state.mcp_core.embed_query(query).await?;
1628 let results = hybrid_searcher
1629 .search(query, query_embedding, namespace, limit, options)
1630 .await?;
1631 return Ok(results.into_iter().map(SearchResultJson::from).collect());
1632 }
1633
1634 let results = state
1635 .rag
1636 .search_with_options(namespace, query, limit, options)
1637 .await?;
1638 Ok(results.into_iter().map(SearchResultJson::from).collect())
1639}
1640
1641async fn list_search_namespaces(state: &HttpState) -> anyhow::Result<Vec<String>> {
1642 Ok(state
1643 .rag
1644 .storage_manager()
1645 .list_namespaces()
1646 .await?
1647 .into_iter()
1648 .map(|(name, _)| name)
1649 .collect())
1650}
1651
1652#[derive(Debug, Deserialize)]
1654pub struct CrossSearchParams {
1655 #[serde(rename = "q")]
1656 pub query: String,
1657 #[serde(default = "default_cross_limit")]
1658 pub limit: usize,
1659 #[serde(default = "default_total_limit")]
1660 pub total_limit: usize,
1661 #[serde(default = "default_mode")]
1662 pub mode: String,
1663}
1664
1665#[derive(Debug, Serialize)]
1667pub struct CrossSearchResponse {
1668 pub results: Vec<SearchResultJson>,
1669 pub clusters: Vec<context_pack::SearchClusterJson>,
1670 pub duplicate_count: usize,
1671 pub query: String,
1672 pub mode: String,
1673 pub namespaces_searched: usize,
1674 pub total_results: usize,
1675 pub elapsed_ms: u64,
1676}
1677
1678#[derive(Debug, Serialize)]
1680pub struct HealthResponse {
1681 pub status: String,
1682 pub db_path: String,
1683 pub embedding_provider: String,
1684 pub schema_version: String,
1685 pub expected_schema: String,
1686 pub needs_migration: bool,
1687 pub missing_columns: Vec<String>,
1688 pub manifest_version: Option<u64>,
1689 pub last_successful_append_at: Option<String>,
1690 pub namespaces: BTreeMap<String, HealthNamespaceStatus>,
1691}
1692
1693#[derive(Debug, Serialize)]
1694pub struct HealthNamespaceStatus {
1695 pub chunks: usize,
1696 pub last_indexed_at: Option<String>,
1697}
1698
1699fn extract_bearer_token(request: &Request, allow_query_token: bool) -> Option<String> {
1701 if let Some(header) = request
1703 .headers()
1704 .get(axum::http::header::AUTHORIZATION)
1705 .and_then(|v| v.to_str().ok())
1706 && let Some(token) = header.strip_prefix("Bearer ")
1707 {
1708 return Some(token.to_string());
1709 }
1710
1711 if allow_query_token && let Some(query) = request.uri().query() {
1713 for pair in query.split('&') {
1714 if let Some(value) = pair.strip_prefix("token=") {
1715 return Some(value.to_string());
1716 }
1717 }
1718 }
1719
1720 None
1721}
1722
1723fn token_matches(provided: &str, expected: &str) -> bool {
1725 let provided_bytes = provided.as_bytes();
1726 let expected_bytes = expected.as_bytes();
1727 provided_bytes.ct_eq(expected_bytes).into()
1728}
1729
1730fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
1731 headers
1732 .get(header::COOKIE)
1733 .and_then(|value| value.to_str().ok())
1734 .and_then(|cookies| {
1735 cookies.split(';').find_map(|cookie| {
1736 let (key, value) = cookie.trim().split_once('=')?;
1737 (key == name).then(|| value.to_string())
1738 })
1739 })
1740}
1741
1742fn dashboard_session_from_request(request: &Request) -> Option<String> {
1743 cookie_value(request.headers(), DASHBOARD_SESSION_COOKIE)
1744}
1745
1746fn route_allows_dashboard_session(method: &Method, path: &str) -> bool {
1747 if path == "/" || path.starts_with("/api/") {
1748 return true;
1749 }
1750
1751 if *method == Method::POST && path == "/search" {
1752 return true;
1753 }
1754
1755 if *method == Method::GET
1756 && (path == "/cross-search"
1757 || path.starts_with("/expand/")
1758 || path.starts_with("/parent/")
1759 || path.starts_with("/get/"))
1760 {
1761 return true;
1762 }
1763
1764 false
1765}
1766
1767fn login_redirect_response() -> axum::response::Response {
1768 (
1769 StatusCode::SEE_OTHER,
1770 [(header::LOCATION, HeaderValue::from_static("/auth/login"))],
1771 )
1772 .into_response()
1773}
1774
1775fn unauthorized_response(
1776 request: &Request,
1777 dashboard_oidc_enabled: bool,
1778) -> axum::response::Response {
1779 let authenticate = [(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"))];
1780 let dashboard_route = route_allows_dashboard_session(request.method(), request.uri().path());
1781
1782 if request.method() == Method::GET && request.uri().path() == "/" {
1783 return if dashboard_oidc_enabled {
1784 login_redirect_response()
1785 } else {
1786 (
1787 StatusCode::UNAUTHORIZED,
1788 authenticate,
1789 Html(DASHBOARD_LOGIN_HTML.to_string()),
1790 )
1791 .into_response()
1792 };
1793 }
1794
1795 if dashboard_oidc_enabled && dashboard_route {
1796 return (
1797 StatusCode::UNAUTHORIZED,
1798 authenticate,
1799 Json(json!({"error": "login required", "login_url": "/auth/login"})),
1800 )
1801 .into_response();
1802 }
1803
1804 (
1805 StatusCode::UNAUTHORIZED,
1806 authenticate,
1807 Json(json!({"error": "missing or invalid auth token"})),
1808 )
1809 .into_response()
1810}
1811
1812async fn auth_middleware(
1821 State(state): State<HttpState>,
1822 request: Request,
1823 next: Next,
1824) -> impl IntoResponse {
1825 let dashboard_oidc_enabled = state.dashboard_oidc.is_some();
1826
1827 if state.auth_mode == AuthMode::AllRoutes {
1828 if let Some(ref expected) = state.auth_token {
1829 let allow_query = state.allow_query_token;
1830 if let Some(token) = extract_bearer_token(&request, allow_query)
1831 && token_matches(&token, expected)
1832 {
1833 return Ok(next.run(request).await);
1834 }
1835 }
1836
1837 if route_allows_dashboard_session(request.method(), request.uri().path())
1838 && let Some(ref oidc) = state.dashboard_oidc
1839 && let Some(session_id) = dashboard_session_from_request(&request)
1840 && oidc.has_session(&session_id).await
1841 {
1842 return Ok(next.run(request).await);
1843 }
1844 }
1845
1846 if state.auth_mode == AuthMode::NamespaceAcl
1848 && let Some(ref manager) = state.auth_manager
1849 {
1850 let allow_query = state.allow_query_token;
1851 let bearer = extract_bearer_token(&request, allow_query);
1852 let bearer = match bearer {
1853 Some(t) => t,
1854 None => {
1855 return Err(unauthorized_response(&request, dashboard_oidc_enabled));
1856 }
1857 };
1858
1859 let required_scope = match *request.method() {
1861 Method::GET | Method::HEAD => crate::auth::Scope::Read,
1862 _ => crate::auth::Scope::Write,
1863 };
1864
1865 let path = request.uri().path().to_string();
1867 let namespace = extract_namespace_from_path(&path);
1868
1869 match manager
1870 .authorize(&bearer, &required_scope, namespace.as_deref())
1871 .await
1872 {
1873 Ok(_) => {}
1874 Err(crate::auth::AuthDenial::MissingToken | crate::auth::AuthDenial::InvalidToken) => {
1875 return Err(unauthorized_response(&request, dashboard_oidc_enabled));
1876 }
1877 Err(crate::auth::AuthDenial::Expired { id }) => {
1878 return Err((
1879 StatusCode::UNAUTHORIZED,
1880 Json(json!({"error": format!("Token '{}' has expired", id)})),
1881 )
1882 .into_response());
1883 }
1884 Err(
1885 denial @ (crate::auth::AuthDenial::InsufficientScope { .. }
1886 | crate::auth::AuthDenial::NamespaceDenied { .. }),
1887 ) => {
1888 return Err((
1889 StatusCode::FORBIDDEN,
1890 Json(json!({"error": denial.to_string()})),
1891 )
1892 .into_response());
1893 }
1894 }
1895
1896 return Ok(next.run(request).await);
1897 }
1898 if let Some(ref expected) = state.auth_token {
1902 let allow_query = state.allow_query_token;
1903 match extract_bearer_token(&request, allow_query) {
1904 Some(token) if token_matches(&token, expected) => {}
1905 _ => {
1906 return Err(unauthorized_response(&request, dashboard_oidc_enabled));
1907 }
1908 }
1909 }
1910 Ok(next.run(request).await)
1911}
1912
1913fn extract_namespace_from_path(path: &str) -> Option<String> {
1918 let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
1919 match segments.as_slice() {
1920 ["api", "browse", ns] => Some(ns.to_string()),
1922 ["ns", ns] => Some(ns.to_string()),
1924 [verb, ns, _id] if matches!(*verb, "expand" | "parent" | "get" | "delete") => {
1926 Some(ns.to_string())
1927 }
1928 _ => None,
1929 }
1930}
1931
1932#[derive(Clone, Debug, PartialEq, Eq)]
1934pub enum AuthMode {
1935 MutatingOnly,
1937 AllRoutes,
1939 NamespaceAcl,
1941}
1942
1943impl AuthMode {
1944 pub fn parse(s: &str) -> Self {
1945 match s {
1946 "all-routes" => Self::AllRoutes,
1947 "namespace-acl" => Self::NamespaceAcl,
1948 _ => Self::MutatingOnly,
1949 }
1950 }
1951}
1952
1953#[derive(Debug, Deserialize)]
1954struct DashboardOidcCallbackParams {
1955 code: Option<String>,
1956 state: Option<String>,
1957 error: Option<String>,
1958 error_description: Option<String>,
1959}
1960
1961fn http_public_base_url(bind_address: IpAddr, port: u16) -> String {
1962 let host = match bind_address {
1963 IpAddr::V4(addr) if addr.is_unspecified() => std::net::Ipv4Addr::LOCALHOST.to_string(),
1964 IpAddr::V4(addr) => addr.to_string(),
1965 IpAddr::V6(addr) if addr.is_unspecified() => format!("[{}]", std::net::Ipv6Addr::LOCALHOST),
1966 IpAddr::V6(addr) => format!("[{addr}]"),
1967 };
1968 format!("http://{host}:{port}")
1969}
1970
1971fn resolve_dashboard_oidc_config(
1972 config: &DashboardOidcConfig,
1973 bind_address: IpAddr,
1974) -> ResolvedDashboardOidcConfig {
1975 let public_base_url = config
1976 .public_base_url
1977 .clone()
1978 .unwrap_or_else(|| http_public_base_url(bind_address, config.server_port));
1979 let public_base_url = public_base_url.trim_end_matches('/').to_string();
1980 let redirect_url = format!("{public_base_url}/auth/callback");
1981
1982 ResolvedDashboardOidcConfig {
1983 issuer_url: config.issuer_url.clone(),
1984 client_id: config.client_id.clone(),
1985 client_secret: config.client_secret.clone(),
1986 public_base_url: public_base_url.clone(),
1987 redirect_url,
1988 scopes: config.scopes.clone(),
1989 secure_cookie: public_base_url.starts_with("https://"),
1990 }
1991}
1992
1993fn dashboard_session_cookie(session_id: &str, secure: bool) -> String {
1994 let secure_attr = if secure { "; Secure" } else { "" };
1995 format!(
1996 "{DASHBOARD_SESSION_COOKIE}={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age={};{secure_attr}",
1997 12 * 60 * 60
1998 )
1999}
2000
2001fn dashboard_session_cookie_clear(secure: bool) -> String {
2002 let secure_attr = if secure { "; Secure" } else { "" };
2003 format!(
2004 "{DASHBOARD_SESSION_COOKIE}=deleted; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;{secure_attr}"
2005 )
2006}
2007
2008fn html_error_page(title: &str, message: &str, status: StatusCode) -> axum::response::Response {
2009 let body = format!(
2010 "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>{title}</title><style>body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;display:grid;place-items:center;min-height:100vh;padding:24px;margin:0}}.card{{max-width:520px;background:#161b22;border:1px solid #30363d;border-radius:12px;padding:28px}}h1{{margin:0 0 12px;color:#58a6ff;font-size:22px}}p{{margin:0 0 18px;color:#8b949e;line-height:1.5}}a{{color:#58a6ff}}</style></head><body><div class=\"card\"><h1>{title}</h1><p>{message}</p><p><a href=\"/\">Return to dashboard</a></p></div></body></html>"
2011 );
2012 (status, Html(body)).into_response()
2013}
2014
2015async fn dashboard_oidc_login_handler(
2016 State(state): State<HttpState>,
2017 request: Request,
2018) -> axum::response::Response {
2019 let Some(oidc) = state.dashboard_oidc.clone() else {
2020 return StatusCode::NOT_FOUND.into_response();
2021 };
2022
2023 if let Some(session_id) = dashboard_session_from_request(&request)
2024 && oidc.has_session(&session_id).await
2025 {
2026 return (
2027 StatusCode::SEE_OTHER,
2028 [(header::LOCATION, HeaderValue::from_static("/"))],
2029 )
2030 .into_response();
2031 }
2032
2033 match oidc.begin_login().await {
2034 Ok(auth_url) => match HeaderValue::from_str(&auth_url) {
2035 Ok(location) => (StatusCode::SEE_OTHER, [(header::LOCATION, location)]).into_response(),
2036 Err(_) => html_error_page(
2037 "OIDC Login Error",
2038 "Provider login URL contained invalid header characters.",
2039 StatusCode::INTERNAL_SERVER_ERROR,
2040 ),
2041 },
2042 Err(err) => {
2043 error!("OIDC login bootstrap failed: {err}");
2044 html_error_page(
2045 "OIDC Login Error",
2046 "rust-memex could not start the dashboard login flow.",
2047 StatusCode::INTERNAL_SERVER_ERROR,
2048 )
2049 }
2050 }
2051}
2052
2053async fn dashboard_oidc_callback_handler(
2054 State(state): State<HttpState>,
2055 Query(params): Query<DashboardOidcCallbackParams>,
2056) -> axum::response::Response {
2057 let Some(oidc) = state.dashboard_oidc.clone() else {
2058 return StatusCode::NOT_FOUND.into_response();
2059 };
2060
2061 if let Some(error_code) = params.error {
2062 let description = params
2063 .error_description
2064 .unwrap_or_else(|| "Provider returned an authentication error.".to_string());
2065 return html_error_page(
2066 "OIDC Login Rejected",
2067 &format!("{error_code}: {description}"),
2068 StatusCode::BAD_REQUEST,
2069 );
2070 }
2071
2072 let Some(code) = params.code else {
2073 return html_error_page(
2074 "OIDC Callback Error",
2075 "Missing authorization code in callback.",
2076 StatusCode::BAD_REQUEST,
2077 );
2078 };
2079 let Some(callback_state) = params.state else {
2080 return html_error_page(
2081 "OIDC Callback Error",
2082 "Missing callback state parameter.",
2083 StatusCode::BAD_REQUEST,
2084 );
2085 };
2086
2087 match oidc.complete_login(code, callback_state).await {
2088 Ok(session_id) => match HeaderValue::from_str(&dashboard_session_cookie(
2089 &session_id,
2090 oidc.config.secure_cookie,
2091 )) {
2092 Ok(cookie_value) => (
2093 StatusCode::SEE_OTHER,
2094 [
2095 (header::LOCATION, HeaderValue::from_static("/")),
2096 (header::SET_COOKIE, cookie_value),
2097 ],
2098 )
2099 .into_response(),
2100 Err(_) => html_error_page(
2101 "OIDC Session Error",
2102 "Dashboard session cookie could not be created.",
2103 StatusCode::INTERNAL_SERVER_ERROR,
2104 ),
2105 },
2106 Err(err) => {
2107 error!("OIDC callback failed: {err}");
2108 html_error_page(
2109 "OIDC Session Error",
2110 "rust-memex could not finish the dashboard login flow.",
2111 StatusCode::BAD_GATEWAY,
2112 )
2113 }
2114 }
2115}
2116
2117async fn dashboard_oidc_logout_handler(
2118 State(state): State<HttpState>,
2119 request: Request,
2120) -> axum::response::Response {
2121 let secure_cookie = state
2122 .dashboard_oidc
2123 .as_ref()
2124 .map(|oidc| oidc.config.secure_cookie)
2125 .unwrap_or(false);
2126
2127 if let Some(ref oidc) = state.dashboard_oidc
2128 && let Some(session_id) = dashboard_session_from_request(&request)
2129 {
2130 oidc.remove_session(&session_id).await;
2131 }
2132
2133 match HeaderValue::from_str(&dashboard_session_cookie_clear(secure_cookie)) {
2134 Ok(cookie_value) => (
2135 StatusCode::SEE_OTHER,
2136 [
2137 (header::LOCATION, HeaderValue::from_static("/")),
2138 (header::SET_COOKIE, cookie_value),
2139 ],
2140 )
2141 .into_response(),
2142 Err(_) => (
2143 StatusCode::SEE_OTHER,
2144 [(header::LOCATION, HeaderValue::from_static("/"))],
2145 )
2146 .into_response(),
2147 }
2148}
2149
2150#[derive(Clone)]
2152pub struct HttpServerConfig {
2153 pub auth_token: Option<String>,
2155 pub dashboard_oidc: Option<DashboardOidcConfig>,
2157 pub cors_origins: Vec<String>,
2159 pub bind_address: IpAddr,
2161 pub auth_mode: AuthMode,
2163 pub allow_query_token: bool,
2165 pub auth_manager: Option<Arc<crate::auth::AuthManager>>,
2167}
2168
2169impl Default for HttpServerConfig {
2170 fn default() -> Self {
2171 Self {
2172 auth_token: None,
2173 dashboard_oidc: None,
2174 cors_origins: Vec::new(),
2175 bind_address: std::net::Ipv4Addr::LOCALHOST.into(),
2176 auth_mode: AuthMode::MutatingOnly,
2177 allow_query_token: false,
2178 auth_manager: None,
2179 }
2180 }
2181}
2182
2183pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router {
2185 let mut state = state;
2186 state.auth_token = config.auth_token.clone();
2187 state.auth_mode = config.auth_mode.clone();
2188 state.allow_query_token = config.allow_query_token;
2189 state.auth_manager = config.auth_manager.clone();
2190
2191 let is_localhost = config.bind_address.is_loopback();
2192
2193 let cors = if is_localhost && config.cors_origins.is_empty() {
2195 CorsLayer::new()
2197 .allow_origin(tower_http::cors::Any)
2198 .allow_methods(tower_http::cors::Any)
2199 .allow_headers(tower_http::cors::Any)
2200 } else if config.cors_origins.is_empty() {
2201 CorsLayer::new()
2203 .allow_methods([Method::GET, Method::POST])
2204 .allow_headers([
2205 axum::http::header::CONTENT_TYPE,
2206 axum::http::header::AUTHORIZATION,
2207 ])
2208 } else if config.cors_origins.iter().any(|o| o == "*") {
2209 CorsLayer::new()
2211 .allow_origin(tower_http::cors::Any)
2212 .allow_methods(tower_http::cors::Any)
2213 .allow_headers(tower_http::cors::Any)
2214 } else {
2215 let origins: Vec<HeaderValue> = config
2217 .cors_origins
2218 .iter()
2219 .filter_map(|o| o.parse().ok())
2220 .collect();
2221 CorsLayer::new()
2222 .allow_origin(origins)
2223 .allow_methods([Method::GET, Method::POST])
2224 .allow_headers([
2225 axum::http::header::CONTENT_TYPE,
2226 axum::http::header::AUTHORIZATION,
2227 ])
2228 };
2229
2230 let all_routes_auth = config.auth_mode == AuthMode::AllRoutes;
2231
2232 let read_routes = Router::new()
2235 .route("/", get(dashboard_handler))
2236 .route("/health", get(health_handler))
2237 .route("/api/discovery", get(discovery_handler))
2238 .route("/api/namespaces", get(namespaces_handler))
2239 .route("/api/overview", get(overview_handler))
2240 .route("/api/status", get(status_handler))
2241 .route("/api/browse", get(browse_all_handler))
2242 .route("/api/browse/", get(browse_all_handler))
2243 .route("/api/browse/{ns}", get(browse_handler))
2244 .route("/search", post(search_handler))
2245 .route("/sse/search", get(sse_search_handler))
2246 .route("/cross-search", get(cross_search_handler))
2247 .route("/sse/cross-search", get(sse_cross_search_handler))
2248 .route("/sse/namespaces", get(sse_namespaces_handler))
2249 .route(
2250 "/api/context-pack",
2251 post(context_pack::context_pack_handler),
2252 )
2253 .route("/expand/{ns}/{id}", get(expand_handler))
2254 .route("/parent/{ns}/{id}", get(parent_handler))
2255 .route("/get/{ns}/{id}", get(get_handler))
2256 .merge(diagnostic_routes());
2257
2258 let read_routes = if all_routes_auth {
2260 read_routes.route_layer(middleware::from_fn_with_state(
2261 state.clone(),
2262 auth_middleware,
2263 ))
2264 } else {
2265 read_routes
2266 };
2267
2268 let authed_routes = Router::new()
2270 .route("/refresh", post(refresh_handler))
2271 .route("/upsert", post(upsert_handler))
2272 .route("/index", post(index_handler))
2273 .route("/delete/{ns}/{id}", post(delete_handler))
2274 .route("/ns/{namespace}", delete(purge_namespace_handler))
2275 .merge(diagnostic_authed_routes())
2276 .merge(lifecycle_routes())
2277 .merge(recovery_routes())
2278 .route_layer(middleware::from_fn_with_state(
2279 state.clone(),
2280 auth_middleware,
2281 ));
2282
2283 let mcp_routes = Router::new()
2285 .route("/mcp/", get(mcp_sse_handler))
2286 .route("/mcp/messages/", post(mcp_messages_handler))
2287 .route("/sse/", get(mcp_sse_handler))
2288 .route("/messages/", post(mcp_messages_handler))
2289 .route_layer(middleware::from_fn_with_state(
2290 state.clone(),
2291 auth_middleware,
2292 ));
2293
2294 let public_routes = Router::new()
2295 .route("/auth/login", get(dashboard_oidc_login_handler))
2296 .route("/auth/callback", get(dashboard_oidc_callback_handler))
2297 .route("/auth/logout", get(dashboard_oidc_logout_handler));
2298
2299 public_routes
2300 .merge(read_routes)
2301 .merge(authed_routes)
2302 .merge(mcp_routes)
2303 .layer(cors)
2304 .with_state(state)
2305}
2306
2307fn lifecycle_routes() -> Router<HttpState> {
2308 lifecycle::routes()
2309}
2310
2311fn recovery_routes() -> Router<HttpState> {
2312 recovery::routes()
2313}
2314
2315fn diagnostic_routes() -> Router<HttpState> {
2316 Router::new()
2317 .route("/api/audit", get(audit_handler))
2318 .route("/api/stats", get(database_stats_handler))
2319 .route("/api/stats/{ns}", get(namespace_stats_handler))
2320 .route("/api/timeline", get(timeline_handler))
2321}
2322
2323fn diagnostic_authed_routes() -> Router<HttpState> {
2324 Router::new()
2325 .route("/api/purge-quality", post(purge_quality_handler))
2326 .route("/api/dedup", post(dedup_handler))
2327 .route("/api/backfill-hashes", post(backfill_hashes_handler))
2328}
2329
2330async fn health_handler(State(state): State<HttpState>) -> impl IntoResponse {
2332 Json(build_health_response(&state).await)
2333}
2334
2335async fn build_health_response(state: &HttpState) -> HealthResponse {
2336 let expected_schema = SchemaVersion::current();
2337 let schema_status = state
2338 .rag
2339 .storage_manager()
2340 .schema_status(expected_schema)
2341 .await
2342 .ok();
2343 let namespace_counts = state
2344 .rag
2345 .storage_manager()
2346 .list_namespaces()
2347 .await
2348 .unwrap_or_default();
2349 let activity = state.namespace_activity.read().await;
2350 let namespaces = namespace_counts
2351 .into_iter()
2352 .map(|(namespace, chunks)| {
2353 (
2354 namespace.clone(),
2355 HealthNamespaceStatus {
2356 chunks,
2357 last_indexed_at: activity.get(&namespace).cloned(),
2358 },
2359 )
2360 })
2361 .collect::<BTreeMap<_, _>>();
2362 let last_successful_append_at = state.last_successful_append_at.read().await.clone();
2363
2364 let (schema_version, expected_schema, needs_migration, missing_columns, manifest_version) =
2365 schema_status
2366 .map(|status| {
2367 (
2368 health_schema_version_label(status.schema_version, status.needs_migration),
2369 status.expected_schema.to_string(),
2370 status.needs_migration,
2371 status.missing_columns,
2372 status.manifest_version,
2373 )
2374 })
2375 .unwrap_or_else(|| {
2376 (
2377 "unknown".to_string(),
2378 expected_schema.to_string(),
2379 false,
2380 Vec::new(),
2381 None,
2382 )
2383 });
2384
2385 HealthResponse {
2386 status: if needs_migration {
2387 "needs_migration"
2388 } else {
2389 "ok"
2390 }
2391 .to_string(),
2392 db_path: state.rag.storage_manager().lance_path().to_string(),
2393 embedding_provider: state.rag.mlx_connected_to(),
2394 schema_version,
2395 expected_schema,
2396 needs_migration,
2397 missing_columns,
2398 manifest_version,
2399 last_successful_append_at,
2400 namespaces,
2401 }
2402}
2403
2404fn health_schema_version_label(version: SchemaVersion, needs_migration: bool) -> String {
2405 if needs_migration && matches!(version, SchemaVersion::V3) {
2406 "v3-pre".to_string()
2407 } else {
2408 version.to_string()
2409 }
2410}
2411
2412#[derive(Debug, Clone)]
2417struct DiscoverySnapshot {
2418 cache_ready: bool,
2419 hint: String,
2420 namespaces: Vec<DiscoveryNamespaceInfo>,
2421}
2422
2423async fn build_discovery_snapshot(state: &HttpState) -> DiscoverySnapshot {
2424 let refresh_error = refresh_namespace_cache(state).await.err();
2425 let cache = state.cached_namespaces.read().await;
2426 let activity = state.namespace_activity.read().await;
2427 let cache_ready = refresh_error.is_none();
2428
2429 let namespaces: Vec<DiscoveryNamespaceInfo> = cache
2430 .as_ref()
2431 .map(|ns_list| {
2432 let mut sorted = ns_list.clone();
2433 sorted.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
2434
2435 sorted
2436 .iter()
2437 .map(|ns| DiscoveryNamespaceInfo {
2438 id: ns.name.clone(),
2439 count: ns.count,
2440 last_indexed_at: activity.get(&ns.name).cloned(),
2441 })
2442 .collect()
2443 })
2444 .unwrap_or_default();
2445
2446 DiscoverySnapshot {
2447 cache_ready,
2448 hint: refresh_error
2449 .map(|error| format!("{}: {}", discovery_hint(false), error))
2450 .unwrap_or_else(|| discovery_hint(true).to_string()),
2451 namespaces,
2452 }
2453}
2454
2455async fn build_discovery_response(state: &HttpState) -> DiscoveryResponse {
2456 let snapshot = build_discovery_snapshot(state).await;
2457 let stats = state.rag.storage_manager().stats().await.ok();
2458 let total_documents = stats
2459 .as_ref()
2460 .map(|stats| stats.row_count)
2461 .unwrap_or_else(|| snapshot.namespaces.iter().map(|ns| ns.count).sum());
2462 let db_path = stats
2463 .as_ref()
2464 .map(|stats| stats.db_path.clone())
2465 .unwrap_or_else(|| state.rag.storage_manager().lance_path().to_string());
2466
2467 DiscoveryResponse {
2468 status: if snapshot.cache_ready {
2469 "ok"
2470 } else if snapshot.namespaces.is_empty() {
2471 "error"
2472 } else {
2473 "stale"
2474 }
2475 .to_string(),
2476 hint: snapshot.hint,
2477 version: env!("CARGO_PKG_VERSION").to_string(),
2478 db_path,
2479 embedding_provider: state.rag.mlx_connected_to(),
2480 total_documents,
2481 namespace_count: snapshot.namespaces.len(),
2482 namespaces: snapshot.namespaces,
2483 }
2484}
2485
2486async fn refresh_namespace_cache(state: &HttpState) -> anyhow::Result<()> {
2487 let ns_list = state.rag.storage_manager().list_namespaces().await?;
2488 let namespaces: Vec<NamespaceInfo> = ns_list
2489 .into_iter()
2490 .map(|(name, count)| NamespaceInfo { name, count })
2491 .collect();
2492 *state.cached_namespaces.write().await = Some(namespaces);
2493 Ok(())
2494}
2495
2496async fn mark_namespace_activity(state: &HttpState, namespace: &str) {
2497 let now = chrono::Utc::now().to_rfc3339();
2498 state
2499 .namespace_activity
2500 .write()
2501 .await
2502 .insert(namespace.to_string(), now.clone());
2503 *state.last_successful_append_at.write().await = Some(now);
2504 if let Err(error) = refresh_namespace_cache(state).await {
2505 warn!(
2506 "Namespace cache refresh failed after activity update: {}",
2507 error
2508 );
2509 }
2510}
2511fn namespaces_response_from_discovery(discovery: &DiscoveryResponse) -> NamespacesResponse {
2512 NamespacesResponse {
2513 total: discovery.namespaces.len(),
2514 namespaces: discovery
2515 .namespaces
2516 .iter()
2517 .map(|ns| NamespaceInfo {
2518 name: ns.id.clone(),
2519 count: ns.count,
2520 })
2521 .collect(),
2522 }
2523}
2524
2525fn overview_response_from_discovery(discovery: &DiscoveryResponse) -> OverviewResponse {
2526 OverviewResponse {
2527 namespace_count: discovery.namespace_count,
2528 total_documents: discovery.total_documents,
2529 db_path: discovery.db_path.clone(),
2530 embedding_provider: discovery.embedding_provider.clone(),
2531 }
2532}
2533
2534fn status_response_from_discovery(discovery: &DiscoveryResponse) -> serde_json::Value {
2535 json!({
2536 "cache_ready": discovery.status == "ok",
2537 "namespace_count": discovery.namespace_count,
2538 "hint": discovery.hint,
2539 })
2540}
2541
2542const DASHBOARD_LOGIN_HTML: &str = r##"<!DOCTYPE html>
2546<html lang="en">
2547<head>
2548<meta charset="UTF-8">
2549<meta name="viewport" content="width=device-width, initial-scale=1.0">
2550<title>rust-memex - Login Required</title>
2551<style>
2552body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
2553.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; max-width: 400px; width: 100%; }
2554h2 { margin: 0 0 16px; color: #58a6ff; }
2555p { color: #8b949e; margin: 0 0 24px; font-size: 14px; }
2556input { width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 14px; box-sizing: border-box; margin-bottom: 16px; }
2557button { width: 100%; padding: 10px; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 14px; cursor: pointer; }
2558button:hover { background: #2ea043; }
2559.error { color: #f85149; font-size: 13px; display: none; margin-bottom: 12px; }
2560</style>
2561</head>
2562<body>
2563<div class="card">
2564<h2>rust-memex Dashboard</h2>
2565<p>This server requires authentication. Enter your bearer token to continue.</p>
2566<div class="error" id="err">Invalid token. Please try again.</div>
2567<form id="f" onsubmit="return login()">
2568<input type="password" id="tok" placeholder="Bearer token" autocomplete="off" autofocus>
2569<button type="submit">Authenticate</button>
2570</form>
2571</div>
2572<script>
2573(function() {
2574 var t = localStorage.getItem('memex_token');
2575 if (t) { window.location.href = '/?_authed=1'; }
2576})();
2577function login() {
2578 var tok = document.getElementById('tok').value.trim();
2579 if (!tok) return false;
2580 fetch('/api/discovery', { headers: { 'Authorization': 'Bearer ' + tok } })
2581 .then(function(r) {
2582 if (r.ok) { localStorage.setItem('memex_token', tok); window.location.reload(); }
2583 else { document.getElementById('err').style.display = 'block'; }
2584 });
2585 return false;
2586}
2587</script>
2588</body>
2589</html>"##;
2590
2591async fn dashboard_handler(State(state): State<HttpState>, request: Request) -> impl IntoResponse {
2592 debug!("Dashboard: serving HTML");
2593
2594 if state.auth_mode == AuthMode::AllRoutes
2596 && state.dashboard_oidc.is_none()
2597 && let Some(ref expected) = state.auth_token
2598 {
2599 let allow_query = state.allow_query_token;
2600 let has_valid_token = match extract_bearer_token(&request, allow_query) {
2601 Some(token) => token_matches(&token, expected),
2602 None => false,
2603 };
2604
2605 if !has_valid_token {
2606 return Html(DASHBOARD_LOGIN_HTML.to_string());
2607 }
2608 }
2609
2610 Html(get_dashboard_html())
2611}
2612
2613async fn namespaces_handler(State(state): State<HttpState>) -> Json<NamespacesResponse> {
2615 Json(namespaces_response_from_discovery(
2616 &build_discovery_response(&state).await,
2617 ))
2618}
2619
2620async fn overview_handler(State(state): State<HttpState>) -> Json<OverviewResponse> {
2622 Json(overview_response_from_discovery(
2623 &build_discovery_response(&state).await,
2624 ))
2625}
2626
2627async fn status_handler(State(state): State<HttpState>) -> Json<serde_json::Value> {
2629 Json(status_response_from_discovery(
2630 &build_discovery_response(&state).await,
2631 ))
2632}
2633
2634async fn audit_handler(
2636 State(state): State<HttpState>,
2637 Query(params): Query<AuditParams>,
2638) -> Result<Json<AuditResult>, (StatusCode, String)> {
2639 validate_threshold(params.threshold)?;
2640 let namespace = params
2641 .ns
2642 .as_deref()
2643 .map(str::trim)
2644 .filter(|value| !value.is_empty())
2645 .ok_or_else(|| {
2646 (
2647 StatusCode::BAD_REQUEST,
2648 "ns query parameter is required".to_string(),
2649 )
2650 })?;
2651
2652 let result = diagnostics::audit_namespaces(
2653 state.rag.storage_manager().as_ref(),
2654 Some(namespace),
2655 params.threshold,
2656 )
2657 .await
2658 .map_err(internal_error)?
2659 .into_iter()
2660 .next()
2661 .ok_or_else(|| {
2662 (
2663 StatusCode::NOT_FOUND,
2664 format!("namespace '{namespace}' not found"),
2665 )
2666 })?;
2667
2668 Ok(Json(result))
2669}
2670
2671async fn database_stats_handler(
2673 State(state): State<HttpState>,
2674) -> Result<Json<DatabaseStats>, (StatusCode, String)> {
2675 let stats = diagnostics::database_stats(state.rag.storage_manager().as_ref())
2676 .await
2677 .map_err(internal_error)?;
2678 Ok(Json(stats))
2679}
2680
2681async fn namespace_stats_handler(
2683 State(state): State<HttpState>,
2684 Path(ns): Path<String>,
2685) -> Result<Json<NamespaceStats>, (StatusCode, String)> {
2686 let stats = diagnostics::namespace_stats(state.rag.storage_manager().as_ref(), Some(&ns))
2687 .await
2688 .map_err(internal_error)?;
2689 let result = stats
2690 .into_iter()
2691 .next()
2692 .ok_or_else(|| (StatusCode::NOT_FOUND, format!("namespace '{ns}' not found")))?;
2693 Ok(Json(result))
2694}
2695
2696async fn timeline_handler(
2698 State(state): State<HttpState>,
2699 Query(params): Query<TimelineParams>,
2700) -> Result<Json<Vec<TimelineEntry>>, (StatusCode, String)> {
2701 let bucket = match params.bucket.as_str() {
2702 "day" => TimelineBucket::Day,
2703 "hour" => TimelineBucket::Hour,
2704 _ => {
2705 return Err((
2706 StatusCode::BAD_REQUEST,
2707 "bucket must be 'day' or 'hour'".to_string(),
2708 ));
2709 }
2710 };
2711
2712 let report = diagnostics::timeline_report(
2713 state.rag.storage_manager().as_ref(),
2714 &TimelineQuery {
2715 namespace: params.ns,
2716 since: params.since,
2717 until: params.until,
2718 bucket,
2719 },
2720 )
2721 .await
2722 .map_err(internal_error)?;
2723
2724 Ok(Json(report.entries))
2725}
2726
2727async fn purge_quality_handler(
2729 State(state): State<HttpState>,
2730 Json(request): Json<PurgeQualityRequest>,
2731) -> Result<Json<PurgeQualityResult>, (StatusCode, String)> {
2732 validate_threshold(request.threshold)?;
2733 let key = diagnostic_approval_key(
2734 "purge-quality",
2735 request.namespace.as_deref(),
2736 Some(request.threshold),
2737 );
2738
2739 if request.dry_run {
2740 let result = diagnostics::purge_quality_namespaces(
2741 state.rag.storage_manager().as_ref(),
2742 request.namespace.as_deref(),
2743 request.threshold,
2744 true,
2745 )
2746 .await
2747 .map_err(internal_error)?;
2748 record_diagnostic_dry_run(&state, key).await;
2749 return Ok(Json(result));
2750 }
2751
2752 ensure_destructive_diagnostic_allowed(&state, key, request.confirm, request.allow_single_step)
2753 .await?;
2754
2755 let result = diagnostics::purge_quality_namespaces(
2756 state.rag.storage_manager().as_ref(),
2757 request.namespace.as_deref(),
2758 request.threshold,
2759 false,
2760 )
2761 .await
2762 .map_err(internal_error)?;
2763
2764 Ok(Json(result))
2765}
2766
2767async fn dedup_handler(
2769 State(state): State<HttpState>,
2770 Query(params): Query<DedupParams>,
2771 body: String,
2772) -> Result<Json<DedupResponse>, (StatusCode, String)> {
2773 let request = if body.trim().is_empty() {
2774 DedupRequest::default()
2775 } else {
2776 serde_json::from_str::<DedupRequest>(&body).map_err(|error| {
2777 (
2778 StatusCode::BAD_REQUEST,
2779 format!("invalid dedup request body: {error}"),
2780 )
2781 })?
2782 };
2783
2784 let dry_run = !params.execute;
2785 let key = diagnostic_approval_key("dedup", params.ns.as_deref(), None);
2786
2787 if !dry_run {
2788 ensure_destructive_diagnostic_allowed(
2789 &state,
2790 key.clone(),
2791 request.confirm,
2792 request.allow_single_step,
2793 )
2794 .await?;
2795 }
2796
2797 let group_by = params
2798 .group_by
2799 .as_deref()
2800 .map(diagnostics::DedupGroupBy::parse)
2801 .unwrap_or_default();
2802
2803 let result = diagnostics::deduplicate_documents(
2804 state.rag.storage_manager().as_ref(),
2805 params.ns.as_deref(),
2806 dry_run,
2807 KeepStrategy::Oldest,
2808 false,
2809 group_by,
2810 )
2811 .await
2812 .map_err(internal_error)?;
2813
2814 if dry_run {
2815 record_diagnostic_dry_run(&state, key).await;
2816 }
2817
2818 Ok(Json(DedupResponse {
2819 namespace: params.ns,
2820 execute: params.execute,
2821 dry_run,
2822 result,
2823 }))
2824}
2825
2826async fn backfill_hashes_handler(
2831 State(state): State<HttpState>,
2832 Query(params): Query<BackfillHashesParams>,
2833 body: String,
2834) -> Result<Json<BackfillHashesResponse>, (StatusCode, String)> {
2835 let request = if body.trim().is_empty() {
2836 BackfillHashesRequest::default()
2837 } else {
2838 serde_json::from_str::<BackfillHashesRequest>(&body).map_err(|error| {
2839 (
2840 StatusCode::BAD_REQUEST,
2841 format!("invalid backfill-hashes request body: {error}"),
2842 )
2843 })?
2844 };
2845
2846 let dry_run = !params.execute;
2847 let key = diagnostic_approval_key("backfill-hashes", params.ns.as_deref(), None);
2848
2849 if !dry_run {
2850 ensure_destructive_diagnostic_allowed(
2851 &state,
2852 key.clone(),
2853 request.confirm,
2854 request.allow_single_step,
2855 )
2856 .await?;
2857 }
2858
2859 let result = diagnostics::backfill_chunk_and_source_hashes(
2860 state.rag.storage_manager().as_ref(),
2861 params.ns.as_deref(),
2862 dry_run,
2863 )
2864 .await
2865 .map_err(internal_error)?;
2866
2867 if dry_run {
2868 record_diagnostic_dry_run(&state, key).await;
2869 }
2870
2871 Ok(Json(BackfillHashesResponse {
2872 namespace: params.ns,
2873 execute: params.execute,
2874 dry_run,
2875 result,
2876 }))
2877}
2878
2879async fn browse_handler(
2881 State(state): State<HttpState>,
2882 Path(ns): Path<String>,
2883 Query(params): Query<BrowseParams>,
2884) -> Result<Json<BrowseResponse>, (StatusCode, String)> {
2885 info!(
2886 "API: /api/browse/{} - limit={}, offset={}",
2887 ns, params.limit, params.offset
2888 );
2889
2890 let namespace = if ns.is_empty() {
2891 None
2892 } else {
2893 Some(ns.as_str())
2894 };
2895
2896 let documents: Vec<SearchResultJson> = state
2897 .rag
2898 .storage_manager()
2899 .all_documents_page(namespace, params.offset, params.limit)
2900 .await
2901 .map_err(|e| {
2902 error!("API: /api/browse/{} - error: {}", ns, e);
2903 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
2904 })?
2905 .into_iter()
2906 .map(Into::into)
2907 .collect();
2908
2909 let count = documents.len();
2910 Ok(Json(BrowseResponse {
2911 namespace: if ns.is_empty() { None } else { Some(ns) },
2912 documents,
2913 count,
2914 offset: params.offset,
2915 }))
2916}
2917
2918async fn browse_all_handler(
2920 State(state): State<HttpState>,
2921 Query(params): Query<BrowseParams>,
2922) -> Result<Json<BrowseResponse>, (StatusCode, String)> {
2923 info!(
2924 "API: /api/browse (all) - limit={}, offset={}",
2925 params.limit, params.offset
2926 );
2927
2928 let documents: Vec<SearchResultJson> = state
2929 .rag
2930 .storage_manager()
2931 .all_documents_page(None, params.offset, params.limit)
2932 .await
2933 .map_err(|e| {
2934 error!("API: /api/browse (all) - error: {}", e);
2935 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
2936 })?
2937 .into_iter()
2938 .map(Into::into)
2939 .collect();
2940
2941 let count = documents.len();
2942 Ok(Json(BrowseResponse {
2943 namespace: None,
2944 documents,
2945 count,
2946 offset: params.offset,
2947 }))
2948}
2949
2950async fn refresh_handler(
2952 State(state): State<HttpState>,
2953) -> Result<impl IntoResponse, (StatusCode, String)> {
2954 refresh_namespace_cache(&state).await.map_err(|e| {
2955 (
2956 StatusCode::INTERNAL_SERVER_ERROR,
2957 format!("Refresh failed: {}", e),
2958 )
2959 })?;
2960
2961 Ok(Json(serde_json::json!({
2962 "status": "refreshed",
2963 "message": "LanceDB cache cleared - next query will see fresh data"
2964 })))
2965}
2966
2967async fn search_handler(
2969 State(state): State<HttpState>,
2970 Json(req): Json<SearchRequest>,
2971) -> Result<Json<SearchResponse>, (StatusCode, String)> {
2972 let start = std::time::Instant::now();
2973 let layer_filter = if req.deep {
2974 None
2975 } else {
2976 req.layer
2977 .and_then(SliceLayer::from_u8)
2978 .or(Some(SliceLayer::Outer))
2979 };
2980 let options = SearchOptions {
2981 layer_filter,
2982 project_filter: req.project.clone().filter(|value| !value.trim().is_empty()),
2983 };
2984 let mode = http_search_mode(req.mode.as_str());
2985
2986 let results = search_results_with_mode(
2987 &state,
2988 Some(req.namespace.as_deref().unwrap_or("default")),
2989 &req.query,
2990 req.limit,
2991 mode,
2992 options,
2993 )
2994 .await
2995 .map_err(|e| {
2996 error!("Search error: {}", e);
2997 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
2998 })?;
2999
3000 let count = results.len();
3001 let clusters = context_pack::collapse_results(&results);
3002 let duplicate_count = count.saturating_sub(clusters.len());
3003
3004 Ok(Json(SearchResponse {
3005 results,
3006 clusters,
3007 duplicate_count,
3008 query: req.query,
3009 namespace: req.namespace,
3010 elapsed_ms: start.elapsed().as_millis() as u64,
3011 count,
3012 }))
3013}
3014
3015async fn sse_search_handler(
3017 State(state): State<HttpState>,
3018 Query(params): Query<SseSearchParams>,
3019) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
3020 let stream = async_stream::stream! {
3021 yield Ok(Event::default()
3023 .event("start")
3024 .data(serde_json::json!({
3025 "query": params.query,
3026 "namespace": params.namespace,
3027 "limit": params.limit,
3028 "mode": params.mode,
3029 "deep": params.deep,
3030 "layer": params.layer,
3031 "project": params.project
3032 }).to_string()));
3033
3034 let namespace = params.namespace.as_deref().unwrap_or("default");
3035 let layer_filter = if params.deep {
3036 None
3037 } else {
3038 params.layer.and_then(SliceLayer::from_u8).or(Some(SliceLayer::Outer))
3039 };
3040 let options = SearchOptions {
3041 layer_filter,
3042 project_filter: params.project.clone().filter(|value| !value.trim().is_empty()),
3043 };
3044 let mode = http_search_mode(params.mode.as_str());
3045
3046 match search_results_with_mode(
3047 &state,
3048 Some(namespace),
3049 ¶ms.query,
3050 params.limit,
3051 mode,
3052 options,
3053 )
3054 .await
3055 {
3056 Ok(results) => {
3057 let total = results.len();
3058
3059 for (i, result) in results.into_iter().enumerate() {
3060 if let Ok(json) = serde_json::to_string(&result) {
3061 yield Ok(Event::default()
3062 .event("result")
3063 .id(i.to_string())
3064 .data(json));
3065 }
3066
3067 tokio::time::sleep(Duration::from_millis(5)).await;
3069 }
3070
3071 yield Ok(Event::default()
3072 .event("done")
3073 .data(serde_json::json!({
3074 "status": "complete",
3075 "total": total
3076 }).to_string()));
3077 }
3078 Err(e) => {
3079 yield Ok(Event::default()
3080 .event("error")
3081 .data(serde_json::json!({"error": e.to_string()}).to_string()));
3082 }
3083 }
3084 };
3085
3086 Sse::new(stream).keep_alive(
3087 axum::response::sse::KeepAlive::new()
3088 .interval(Duration::from_secs(15))
3089 .text("ping"),
3090 )
3091}
3092
3093async fn cross_search_handler(
3096 State(state): State<HttpState>,
3097 Query(params): Query<CrossSearchParams>,
3098) -> Result<Json<CrossSearchResponse>, (StatusCode, String)> {
3099 let start = std::time::Instant::now();
3100 let mode = http_search_mode(params.mode.as_str());
3101 let namespaces = list_search_namespaces(&state).await.map_err(|e| {
3102 error!("Cross-search namespace lookup error: {}", e);
3103 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
3104 })?;
3105 let namespaces_count = namespaces.len();
3106
3107 if namespaces.is_empty() {
3108 return Ok(Json(CrossSearchResponse {
3109 results: vec![],
3110 clusters: vec![],
3111 duplicate_count: 0,
3112 query: params.query,
3113 mode: params.mode,
3114 namespaces_searched: 0,
3115 total_results: 0,
3116 elapsed_ms: start.elapsed().as_millis() as u64,
3117 }));
3118 }
3119
3120 let mut all_results: Vec<(SearchResultJson, f32)> = Vec::new();
3122
3123 for ns in &namespaces {
3124 match search_results_with_mode(
3125 &state,
3126 Some(ns),
3127 ¶ms.query,
3128 params.limit,
3129 mode,
3130 SearchOptions::default(),
3131 )
3132 .await
3133 {
3134 Ok(results) => {
3135 for r in results {
3136 let score = r.score;
3137 all_results.push((r, score));
3138 }
3139 }
3140 Err(e) => {
3141 error!("Cross-search error in namespace '{}': {}", ns, e);
3143 }
3144 }
3145 }
3146
3147 all_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3149
3150 all_results.truncate(params.total_limit);
3152
3153 let results: Vec<SearchResultJson> = all_results.into_iter().map(|(r, _)| r).collect();
3154 let total_results = results.len();
3155 let clusters = context_pack::collapse_results(&results);
3156 let duplicate_count = total_results.saturating_sub(clusters.len());
3157
3158 Ok(Json(CrossSearchResponse {
3159 results,
3160 clusters,
3161 duplicate_count,
3162 query: params.query,
3163 mode: params.mode,
3164 namespaces_searched: namespaces_count,
3165 total_results,
3166 elapsed_ms: start.elapsed().as_millis() as u64,
3167 }))
3168}
3169
3170async fn sse_cross_search_handler(
3173 State(state): State<HttpState>,
3174 Query(params): Query<CrossSearchParams>,
3175) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
3176 let stream = async_stream::stream! {
3177 yield Ok(Event::default()
3179 .event("start")
3180 .data(serde_json::json!({
3181 "query": params.query,
3182 "limit_per_ns": params.limit,
3183 "total_limit": params.total_limit,
3184 "mode": params.mode
3185 }).to_string()));
3186
3187 let namespaces = match list_search_namespaces(&state).await {
3189 Ok(namespaces) => namespaces,
3190 Err(e) => {
3191 yield Ok(Event::default()
3192 .event("error")
3193 .data(serde_json::json!({"error": e.to_string()}).to_string()));
3194 return;
3195 }
3196 };
3197 let mode = http_search_mode(params.mode.as_str());
3198
3199 yield Ok(Event::default()
3201 .event("namespaces")
3202 .data(serde_json::json!({
3203 "count": namespaces.len(),
3204 "namespaces": namespaces
3205 }).to_string()));
3206
3207 let mut all_results: Vec<(SearchResultJson, f32, String)> = Vec::new();
3209
3210 for ns in &namespaces {
3212 yield Ok(Event::default()
3213 .event("searching")
3214 .data(serde_json::json!({"namespace": ns}).to_string()));
3215
3216 match search_results_with_mode(
3217 &state,
3218 Some(ns),
3219 ¶ms.query,
3220 params.limit,
3221 mode,
3222 SearchOptions::default(),
3223 ).await {
3224 Ok(results) => {
3225 let ns_count = results.len();
3226 for result in results {
3227 let score = result.score;
3228 all_results.push((result, score, ns.clone()));
3229 }
3230
3231 yield Ok(Event::default()
3232 .event("namespace_done")
3233 .data(serde_json::json!({
3234 "namespace": ns,
3235 "results_found": ns_count
3236 }).to_string()));
3237 }
3238 Err(e) => {
3239 yield Ok(Event::default()
3240 .event("namespace_error")
3241 .data(serde_json::json!({
3242 "namespace": ns,
3243 "error": e.to_string()
3244 }).to_string()));
3245 }
3246 }
3247
3248 tokio::time::sleep(Duration::from_millis(5)).await;
3250 }
3251
3252 all_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3254
3255 all_results.truncate(params.total_limit);
3257
3258 for (i, (result, _score, _ns)) in all_results.iter().enumerate() {
3259 if let Ok(json) = serde_json::to_string(&result) {
3260 yield Ok(Event::default()
3261 .event("result")
3262 .id(i.to_string())
3263 .data(json));
3264 }
3265 tokio::time::sleep(Duration::from_millis(5)).await;
3266 }
3267
3268 yield Ok(Event::default()
3269 .event("done")
3270 .data(serde_json::json!({
3271 "status": "complete",
3272 "total_results": all_results.len(),
3273 "namespaces_searched": namespaces.len()
3274 }).to_string()));
3275 };
3276
3277 Sse::new(stream).keep_alive(
3278 axum::response::sse::KeepAlive::new()
3279 .interval(Duration::from_secs(15))
3280 .text("ping"),
3281 )
3282}
3283
3284fn discovery_hint(cache_ready: bool) -> &'static str {
3290 if cache_ready {
3291 "OK"
3292 } else {
3293 "Namespace cache loading... If this persists, run: rust-memex optimize"
3294 }
3295}
3296
3297async fn discovery_handler(State(state): State<HttpState>) -> Json<DiscoveryResponse> {
3298 Json(build_discovery_response(&state).await)
3299}
3300
3301async fn sse_namespaces_handler(
3304 State(state): State<HttpState>,
3305) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
3306 let stream = async_stream::stream! {
3307 let start = std::time::Instant::now();
3308
3309 yield Ok(Event::default()
3310 .event("start")
3311 .data(serde_json::json!({
3312 "status": "scanning_namespaces"
3313 }).to_string()));
3314
3315 let namespaces = match state.rag.storage_manager().list_namespaces().await {
3317 Ok(ns) => ns,
3318 Err(e) => {
3319 yield Ok(Event::default()
3320 .event("error")
3321 .data(serde_json::json!({"error": e.to_string()}).to_string()));
3322 return;
3323 }
3324 };
3325
3326 let total_namespaces = namespaces.len();
3327 let total_docs: usize = namespaces.iter().map(|(_, c)| *c).sum();
3328
3329 yield Ok(Event::default()
3330 .event("overview")
3331 .data(serde_json::json!({
3332 "total_namespaces": total_namespaces,
3333 "total_documents": total_docs
3334 }).to_string()));
3335
3336 for (i, (ns_name, doc_count)) in namespaces.iter().enumerate() {
3338 let mut layer_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
3340 let mut all_keywords: Vec<String> = Vec::new();
3341
3342 if let Ok(docs) = state.rag.storage_manager().get_all_in_namespace(ns_name).await {
3343 for doc in &docs {
3344 let layer_name = SliceLayer::from_u8(doc.layer)
3345 .map(|l| l.name().to_string())
3346 .unwrap_or_else(|| "flat".to_string());
3347 *layer_counts.entry(layer_name).or_insert(0) += 1;
3348
3349 for kw in &doc.keywords {
3350 if all_keywords.len() < 20 && !all_keywords.contains(kw) {
3351 all_keywords.push(kw.clone());
3352 }
3353 }
3354 }
3355 }
3356
3357 let ns_summary = serde_json::json!({
3358 "name": ns_name,
3359 "document_count": doc_count,
3360 "layers": layer_counts,
3361 "sample_keywords": all_keywords,
3362 "index": i,
3363 });
3364
3365 yield Ok(Event::default()
3366 .event("namespace")
3367 .id(i.to_string())
3368 .data(ns_summary.to_string()));
3369
3370 tokio::time::sleep(Duration::from_millis(5)).await;
3371 }
3372
3373 yield Ok(Event::default()
3374 .event("done")
3375 .data(serde_json::json!({
3376 "status": "complete",
3377 "total_namespaces": total_namespaces,
3378 "total_documents": total_docs,
3379 "elapsed_ms": start.elapsed().as_millis() as u64
3380 }).to_string()));
3381 };
3382
3383 Sse::new(stream).keep_alive(
3384 axum::response::sse::KeepAlive::new()
3385 .interval(Duration::from_secs(15))
3386 .text("ping"),
3387 )
3388}
3389
3390async fn upsert_handler(
3392 State(state): State<HttpState>,
3393 Json(req): Json<UpsertRequest>,
3394) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3395 let metadata = req.metadata.unwrap_or(serde_json::json!({}));
3396
3397 state
3398 .rag
3399 .storage_manager()
3400 .require_current_schema_for_writes()
3401 .await
3402 .map_err(|e| write_error_response("upsert", &req.namespace, e))?;
3403
3404 state
3405 .rag
3406 .memory_upsert(
3407 &req.namespace,
3408 req.id.clone(),
3409 req.content.clone(),
3410 metadata,
3411 )
3412 .await
3413 .map_err(|e| write_error_response("upsert", &req.namespace, e))?;
3414
3415 mark_namespace_activity(&state, &req.namespace).await;
3416
3417 Ok(Json(serde_json::json!({
3418 "status": "ok",
3419 "id": req.id,
3420 "namespace": req.namespace
3421 })))
3422}
3423
3424async fn index_handler(
3426 State(state): State<HttpState>,
3427 Json(req): Json<IndexRequest>,
3428) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3429 use crate::rag::SliceMode;
3430
3431 let mode = match req.slice_mode.as_str() {
3432 "onion" => SliceMode::Onion,
3433 "onion_fast" | "fast" => SliceMode::OnionFast,
3434 _ => SliceMode::Flat,
3435 };
3436
3437 state
3438 .rag
3439 .storage_manager()
3440 .require_current_schema_for_writes()
3441 .await
3442 .map_err(|e| write_error_response("index", &req.namespace, e))?;
3443
3444 let id = format!(
3446 "idx_{}",
3447 uuid::Uuid::new_v4()
3448 .to_string()
3449 .split('-')
3450 .next()
3451 .unwrap_or("000")
3452 );
3453
3454 let result_id = state
3455 .rag
3456 .index_text_with_mode(
3457 Some(&req.namespace),
3458 id,
3459 req.content.clone(),
3460 serde_json::json!({}),
3461 mode,
3462 )
3463 .await
3464 .map_err(|e| write_error_response("index", &req.namespace, e))?;
3465
3466 mark_namespace_activity(&state, &req.namespace).await;
3467
3468 Ok(Json(serde_json::json!({
3469 "status": "indexed",
3470 "namespace": req.namespace,
3471 "id": result_id,
3472 "slice_mode": req.slice_mode
3473 })))
3474}
3475
3476fn write_error_response(
3477 operation: &str,
3478 namespace: &str,
3479 error: anyhow::Error,
3480) -> (StatusCode, Json<serde_json::Value>) {
3481 if let Some(schema_error) = error.downcast_ref::<SchemaMismatchWriteError>() {
3482 let remediation = schema_error.remediation();
3483 error!(
3484 error_kind = "schema_mismatch",
3485 operation = %operation,
3486 namespace = %namespace,
3487 db_path = %schema_error.db_path(),
3488 missing_columns = ?schema_error.missing_columns(),
3489 remediation = %remediation,
3490 file = file!(),
3491 line = line!(),
3492 "HTTP write failed due to schema mismatch"
3493 );
3494 return (
3495 StatusCode::PRECONDITION_FAILED,
3496 Json(json!({
3497 "error": "schema_mismatch",
3498 "error_kind": "schema_mismatch",
3499 "missing_columns": schema_error.missing_columns(),
3500 "remediation": remediation,
3501 })),
3502 );
3503 }
3504
3505 error!(
3506 operation = %operation,
3507 namespace = %namespace,
3508 file = file!(),
3509 line = line!(),
3510 "HTTP write failed: {error}"
3511 );
3512 (
3513 StatusCode::INTERNAL_SERVER_ERROR,
3514 Json(json!({
3515 "error": "internal",
3516 "error_kind": "internal",
3517 "message": error.to_string(),
3518 })),
3519 )
3520}
3521
3522async fn expand_handler(
3524 State(state): State<HttpState>,
3525 Path((ns, id)): Path<(String, String)>,
3526) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
3527 let children = state.rag.expand_result(&ns, &id).await.map_err(|e| {
3528 error!("Expand error: {}", e);
3529 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
3530 })?;
3531
3532 let results: Vec<SearchResultJson> = children.into_iter().map(Into::into).collect();
3533
3534 Ok(Json(serde_json::json!({
3535 "parent_id": id,
3536 "namespace": ns,
3537 "children": results,
3538 "count": results.len()
3539 })))
3540}
3541
3542async fn parent_handler(
3544 State(state): State<HttpState>,
3545 Path((ns, id)): Path<(String, String)>,
3546) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
3547 match state.rag.get_parent_result(&ns, &id).await {
3548 Ok(Some(parent)) => {
3549 let result: SearchResultJson = parent.into();
3550 Ok(Json(serde_json::json!({
3551 "child_id": id,
3552 "namespace": ns,
3553 "parent": result
3554 })))
3555 }
3556 Ok(None) => Err((StatusCode::NOT_FOUND, format!("No parent for '{}'", id))),
3557 Err(e) => {
3558 error!("Parent error: {}", e);
3559 Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
3560 }
3561 }
3562}
3563
3564async fn get_handler(
3566 State(state): State<HttpState>,
3567 Path((ns, id)): Path<(String, String)>,
3568) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
3569 match state.rag.lookup_memory(&ns, &id).await {
3570 Ok(Some(r)) => {
3571 let result: SearchResultJson = r.into();
3572 Ok(Json(serde_json::json!(result)))
3573 }
3574 Ok(None) => Err((
3575 StatusCode::NOT_FOUND,
3576 format!("Document '{}' not found in '{}'", id, ns),
3577 )),
3578 Err(e) => {
3579 error!("Get error: {}", e);
3580 Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
3581 }
3582 }
3583}
3584
3585async fn delete_handler(
3587 State(state): State<HttpState>,
3588 Path((ns, id)): Path<(String, String)>,
3589) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
3590 match state.rag.remove_memory(&ns, &id).await {
3591 Ok(deleted) => {
3592 if deleted > 0
3593 && let Err(error) = refresh_namespace_cache(&state).await
3594 {
3595 warn!("Namespace cache refresh failed after delete: {}", error);
3596 }
3597 Ok(Json(serde_json::json!({
3598 "status": if deleted > 0 { "deleted" } else { "not_found" },
3599 "id": id,
3600 "namespace": ns
3601 })))
3602 }
3603 Err(e) => {
3604 error!("Delete error: {}", e);
3605 Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
3606 }
3607 }
3608}
3609
3610async fn purge_namespace_handler(
3612 State(state): State<HttpState>,
3613 Path(namespace): Path<String>,
3614) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
3615 match state.rag.clear_namespace(&namespace).await {
3616 Ok(deleted) => {
3617 state.namespace_activity.write().await.remove(&namespace);
3618 if let Err(error) = refresh_namespace_cache(&state).await {
3619 warn!("Namespace cache refresh failed after purge: {}", error);
3620 }
3621 Ok(Json(serde_json::json!({
3622 "status": "purged",
3623 "namespace": namespace,
3624 "deleted_count": deleted
3625 })))
3626 }
3627 Err(e) => {
3628 error!("Purge error: {}", e);
3629 Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
3630 }
3631 }
3632}
3633
3634#[derive(Debug, Deserialize)]
3640pub struct McpMessagesParams {
3641 pub session_id: Option<String>,
3642}
3643
3644async fn mcp_sse_handler(
3647 State(state): State<HttpState>,
3648 headers: axum::http::HeaderMap,
3649) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
3650 let (session_id, mut rx) = state.mcp_sessions.create_session().await;
3652
3653 let base_url = if let Some(host) = headers.get(axum::http::header::HOST) {
3655 if let Ok(host_str) = host.to_str() {
3656 format!("http://{}", host_str)
3657 } else {
3658 state.mcp_base_url.read().await.clone()
3659 }
3660 } else {
3661 state.mcp_base_url.read().await.clone()
3662 };
3663
3664 info!(
3665 "MCP SSE: New session {} (base_url: {})",
3666 session_id, base_url
3667 );
3668
3669 let sessions_for_cleanup = state.mcp_sessions.clone();
3670 let session_id_for_cleanup = session_id.clone();
3671
3672 let stream = async_stream::stream! {
3673 let endpoint_url = format!("{}/messages/?session_id={}", base_url, session_id);
3675 yield Ok(Event::default()
3676 .event("endpoint")
3677 .data(endpoint_url));
3678
3679 loop {
3681 tokio::select! {
3682 result = rx.recv() => {
3684 match result {
3685 Ok(response) => {
3686 if let Ok(json_str) = serde_json::to_string(&response) {
3687 yield Ok(Event::default()
3688 .event("message")
3689 .data(json_str));
3690 }
3691 }
3692 Err(broadcast::error::RecvError::Closed) => {
3693 debug!("MCP SSE: Session {} channel closed", session_id);
3694 break;
3695 }
3696 Err(broadcast::error::RecvError::Lagged(n)) => {
3697 warn!("MCP SSE: Session {} lagged {} messages", session_id, n);
3698 }
3699 }
3700 }
3701 _ = tokio::time::sleep(Duration::from_secs(30)) => {
3703 }
3705 }
3706 }
3707
3708 debug!("MCP SSE: Removing session {} on stream drop", session_id_for_cleanup);
3710 sessions_for_cleanup.remove_session(&session_id_for_cleanup).await;
3711 };
3712
3713 Sse::new(stream).keep_alive(
3714 axum::response::sse::KeepAlive::new()
3715 .interval(Duration::from_secs(15))
3716 .text("ping"),
3717 )
3718}
3719
3720async fn mcp_messages_handler(
3724 State(state): State<HttpState>,
3725 Query(params): Query<McpMessagesParams>,
3726 body: String,
3727) -> Result<StatusCode, (StatusCode, String)> {
3728 let session_id = params.session_id.ok_or_else(|| {
3729 (
3730 StatusCode::BAD_REQUEST,
3731 "session_id is required".to_string(),
3732 )
3733 })?;
3734
3735 let session = state
3737 .mcp_sessions
3738 .get_session(&session_id)
3739 .await
3740 .ok_or_else(|| {
3741 (
3742 StatusCode::NOT_FOUND,
3743 format!("Session {} not found", session_id),
3744 )
3745 })?;
3746
3747 debug!(
3748 "MCP: session={} payload_bytes={}",
3749 session_id,
3750 body.trim().len()
3751 );
3752
3753 if let Some(response) =
3754 dispatch_mcp_payload(state.mcp_core.as_ref(), &body, McpTransport::HttpSse).await
3755 && let Err(e) = session.tx.send(response)
3756 {
3757 warn!(
3758 "MCP: Failed to send response to session {}: {}",
3759 session_id, e
3760 );
3761 return Err((
3762 StatusCode::INTERNAL_SERVER_ERROR,
3763 "Failed to send response".to_string(),
3764 ));
3765 }
3766
3767 Ok(StatusCode::ACCEPTED)
3769}
3770
3771pub async fn start_server(
3773 mcp_core: Arc<McpCore>,
3774 port: u16,
3775 server_config: HttpServerConfig,
3776) -> anyhow::Result<()> {
3777 let rag = mcp_core.rag();
3778 let base_url = format!("http://{}:{}", server_config.bind_address, port);
3780 let cached_namespaces = Arc::new(RwLock::new(None));
3781 let dashboard_oidc = server_config.dashboard_oidc.as_ref().map(|config| {
3782 Arc::new(DashboardOidcRuntime::new(resolve_dashboard_oidc_config(
3783 config,
3784 server_config.bind_address,
3785 )))
3786 });
3787
3788 if server_config.auth_token.is_some() {
3790 let mode_label = match server_config.auth_mode {
3791 AuthMode::MutatingOnly => "mutating endpoints only",
3792 AuthMode::AllRoutes => "ALL routes",
3793 AuthMode::NamespaceAcl => "namespace ACL (Track C)",
3794 };
3795 info!("HTTP auth: Bearer token required for {}", mode_label);
3796 if server_config.allow_query_token {
3797 info!("HTTP auth: ?token= query parameter enabled for read GETs");
3798 }
3799 if let Some(ref oidc) = dashboard_oidc {
3800 info!(
3801 "Dashboard auth: OIDC enabled at {} (callback: {})",
3802 oidc.config.public_base_url, oidc.config.redirect_url
3803 );
3804 }
3805 } else {
3806 warn!(
3807 "WARNING: HTTP server running without auth token. Set MEMEX_AUTH_TOKEN or use --auth-token."
3808 );
3809 }
3810
3811 let auth_mgr = mcp_core.auth_manager();
3814 if auth_mgr.has_any_tokens().await {
3815 let tokens = auth_mgr.list_tokens().await;
3816 let protected_namespaces: std::collections::BTreeSet<String> = tokens
3817 .iter()
3818 .flat_map(|entry| entry.namespaces.iter().cloned())
3819 .filter(|ns| ns != "*")
3820 .collect();
3821 if protected_namespaces.is_empty() {
3822 warn!(
3823 "Namespace security enabled but NO per-namespace tokens configured (wildcard-only). All namespaces are covered by wildcard tokens."
3824 );
3825 } else {
3826 info!(
3827 "Namespace security: {} namespace(s) with tokens:",
3828 protected_namespaces.len()
3829 );
3830 for ns_name in &protected_namespaces {
3831 let desc = tokens
3833 .iter()
3834 .find(|entry| entry.namespaces.iter().any(|ns| ns == ns_name))
3835 .map(|entry| entry.description.as_str())
3836 .unwrap_or("(no description)");
3837 info!(" - '{}' {}", ns_name, desc);
3838 }
3839 }
3840 }
3841
3842 let state = HttpState {
3843 rag: rag.clone(),
3844 mcp_core,
3845 mcp_sessions: Arc::new(McpSessionManager::new()),
3846 mcp_base_url: Arc::new(RwLock::new(base_url.clone())),
3847 cached_namespaces: cached_namespaces.clone(),
3848 namespace_activity: Arc::new(RwLock::new(HashMap::new())),
3849 last_successful_append_at: Arc::new(RwLock::new(None)),
3850 diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())),
3851 auth_token: server_config.auth_token.clone(),
3852 auth_mode: server_config.auth_mode.clone(),
3853 allow_query_token: server_config.allow_query_token,
3854 auth_manager: server_config.auth_manager.clone(),
3855 dashboard_oidc: dashboard_oidc.clone(),
3856 };
3857
3858 let bg_rag = rag.clone();
3860 let bg_cache = cached_namespaces.clone();
3861 tokio::spawn(async move {
3862 info!("Background: Loading namespace cache (may take a while on large DB)...");
3864 match tokio::time::timeout(
3865 Duration::from_secs(120),
3866 bg_rag.storage_manager().list_namespaces(),
3867 )
3868 .await
3869 {
3870 Ok(Ok(ns_list)) => {
3871 let namespaces: Vec<NamespaceInfo> = ns_list
3872 .into_iter()
3873 .map(|(name, count)| NamespaceInfo { name, count })
3874 .collect();
3875 info!("Background: Cached {} namespaces", namespaces.len());
3876 *bg_cache.write().await = Some(namespaces);
3877 }
3878 Ok(Err(e)) => {
3879 warn!(
3881 "Background: Namespace load FAILED: {} - run 'rust-memex optimize' to fix",
3882 e
3883 );
3884 }
3885 Err(_) => {
3886 warn!("Background: Namespace load timed out (120s) - will retry");
3887 }
3888 }
3889
3890 let mut interval = tokio::time::interval(Duration::from_secs(300));
3892 interval.tick().await; loop {
3895 interval.tick().await;
3896 debug!("Background: Refreshing namespace cache...");
3897 match tokio::time::timeout(
3898 Duration::from_secs(60),
3899 bg_rag.storage_manager().list_namespaces(),
3900 )
3901 .await
3902 {
3903 Ok(Ok(ns_list)) => {
3904 let namespaces: Vec<NamespaceInfo> = ns_list
3905 .into_iter()
3906 .map(|(name, count)| NamespaceInfo { name, count })
3907 .collect();
3908 info!("Background: Refreshed {} namespaces", namespaces.len());
3909 *bg_cache.write().await = Some(namespaces);
3910 }
3911 Ok(Err(e)) => {
3912 warn!(
3913 "Background: Namespace refresh FAILED: {} - run 'rust-memex optimize'",
3914 e
3915 );
3916 }
3917 Err(_) => {
3918 debug!("Background: Namespace refresh timed out");
3919 }
3920 }
3921 }
3922 });
3923
3924 let bg_sessions = state.mcp_sessions.clone();
3926 let bg_dashboard_oidc = state.dashboard_oidc.clone();
3927 tokio::spawn(async move {
3928 let mut interval = tokio::time::interval(Duration::from_secs(300));
3929 interval.tick().await; loop {
3931 interval.tick().await;
3932 bg_sessions.cleanup_old_sessions().await;
3933 if let Some(ref oidc) = bg_dashboard_oidc {
3934 oidc.cleanup().await;
3935 }
3936 }
3937 });
3938
3939 let app = create_router(state, &server_config);
3940
3941 let addr = format!("{}:{}", server_config.bind_address, port);
3942 info!("HTTP/SSE server starting on http://{}", addr);
3943 info!(" Dashboard: http://{}/ (browse memories visually)", addr);
3944 info!(" Discovery: /api/discovery (canonical endpoint)");
3945 info!(" API: /api/namespaces, /api/overview, /api/browse/:ns");
3946 info!(" Search: /search, /sse/search, /cross-search");
3947 info!(" MCP-SSE: /sse/, /messages/");
3948
3949 let listener = tokio::net::TcpListener::bind(&addr).await?;
3950 axum::serve(listener, app).await?;
3951
3952 Ok(())
3953}
3954
3955#[cfg(test)]
3956mod tests {
3957 use super::*;
3958 use crate::{auth::AuthManager, embeddings::EmbeddingClient, storage::StorageManager};
3959 use axum::body::{Body, to_bytes};
3960 use std::sync::Arc;
3961 use tokio::sync::Mutex;
3962 use tower::util::ServiceExt;
3963
3964 async fn build_test_http_state(db_path: &str) -> HttpState {
3965 let embedding_client = Arc::new(Mutex::new(EmbeddingClient::stub_for_tests()));
3966 let storage = Arc::new(StorageManager::new(db_path).await.expect("storage"));
3967 let rag = Arc::new(
3968 RAGPipeline::new(embedding_client.clone(), storage)
3969 .await
3970 .expect("rag"),
3971 );
3972 let tokens_path = std::path::Path::new(db_path)
3977 .parent()
3978 .map(|p| p.join("tokens.json"))
3979 .unwrap_or_else(|| std::path::PathBuf::from(format!("{}-tokens.json", db_path)))
3980 .to_string_lossy()
3981 .to_string();
3982 let auth_manager = Arc::new(AuthManager::new(tokens_path, None));
3983
3984 HttpState {
3985 rag: rag.clone(),
3986 mcp_core: Arc::new(McpCore::new(
3987 rag,
3988 None,
3989 embedding_client,
3990 1024 * 1024,
3991 vec![],
3992 auth_manager,
3993 )),
3994 mcp_sessions: Arc::new(McpSessionManager::new()),
3995 mcp_base_url: Arc::new(RwLock::new("http://127.0.0.1:0/mcp/messages/".to_string())),
3996 cached_namespaces: Arc::new(RwLock::new(None)),
3997 namespace_activity: Arc::new(RwLock::new(HashMap::new())),
3998 last_successful_append_at: Arc::new(RwLock::new(None)),
3999 diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())),
4000 auth_token: None,
4001 auth_mode: AuthMode::MutatingOnly,
4002 allow_query_token: false,
4003 auth_manager: None,
4004 dashboard_oidc: None,
4005 }
4006 }
4007
4008 async fn write_namespace_doc(storage: &StorageManager, namespace: &str, id: &str) {
4009 storage
4010 .add_to_store(vec![ChromaDocument::new_flat(
4011 id.to_string(),
4012 namespace.to_string(),
4013 vec![0.5, 0.25],
4014 json!({"source": "external-test"}),
4015 format!("document for {namespace}"),
4016 )])
4017 .await
4018 .expect("external write");
4019 }
4020
4021 #[test]
4022 fn test_search_request_defaults() {
4023 let json = r#"{"query": "test"}"#;
4024 let req: SearchRequest = serde_json::from_str(json).unwrap();
4025 assert_eq!(req.limit, 10);
4026 assert_eq!(req.mode, "hybrid");
4027 assert!(req.namespace.is_none());
4028 assert!(req.layer.is_none());
4029 assert!(!req.deep);
4030 assert!(req.project.is_none());
4031 }
4032
4033 #[test]
4034 fn test_search_request_accepts_k_alias() {
4035 let json = r#"{"query": "test", "k": 7, "deep": true, "project": "Vista"}"#;
4036 let req: SearchRequest = serde_json::from_str(json).unwrap();
4037 assert_eq!(req.limit, 7);
4038 assert!(req.deep);
4039 assert_eq!(req.project.as_deref(), Some("Vista"));
4040 }
4041
4042 #[test]
4043 fn test_sse_search_params_accept_k_alias() {
4044 let json = r#"{"query":"test","k":9,"deep":true,"project":"Vista","mode":"bm25"}"#;
4045 let params: SseSearchParams = serde_json::from_str(json).unwrap();
4046 assert_eq!(params.limit, 9);
4047 assert!(params.deep);
4048 assert_eq!(params.project.as_deref(), Some("Vista"));
4049 assert_eq!(params.mode, "bm25");
4050 }
4051
4052 #[test]
4053 fn test_search_request_accepts_bm25_mode() {
4054 let json = r#"{"query":"test","mode":"bm25"}"#;
4055 let req: SearchRequest = serde_json::from_str(json).unwrap();
4056 assert_eq!(req.mode, "bm25");
4057 }
4058
4059 #[test]
4060 fn test_http_search_mode_parsing() {
4061 assert_eq!(http_search_mode("vector"), SearchMode::Vector);
4062 assert_eq!(http_search_mode("keyword"), SearchMode::Keyword);
4063 assert_eq!(http_search_mode("bm25"), SearchMode::Keyword);
4064 assert_eq!(http_search_mode("hybrid"), SearchMode::Hybrid);
4065 assert_eq!(http_search_mode("unknown"), SearchMode::Hybrid);
4066 }
4067
4068 #[test]
4069 fn test_index_request_defaults() {
4070 let json = r#"{"namespace": "test", "content": "hello"}"#;
4071 let req: IndexRequest = serde_json::from_str(json).unwrap();
4072 assert_eq!(req.slice_mode, "flat");
4073 }
4074
4075 #[test]
4076 fn test_discovery_hint_matches_cache_state() {
4077 assert_eq!(discovery_hint(true), "OK");
4078 assert!(discovery_hint(false).contains("rust-memex optimize"));
4079 }
4080
4081 #[test]
4082 fn test_dashboard_html_uses_canonical_discovery_endpoint() {
4083 let html = get_dashboard_html();
4084 assert!(html.contains("/api/discovery"));
4085 assert!(!html.contains("/api/status"));
4086 assert!(!html.contains("/api/overview"));
4087 assert!(!html.contains("/api/namespaces"));
4088 }
4089
4090 #[test]
4091 fn test_compatibility_slices_project_single_discovery_truth() {
4092 let discovery = DiscoveryResponse {
4093 status: "ok".to_string(),
4094 hint: "OK".to_string(),
4095 version: "0.4.1".to_string(),
4096 db_path: "/tmp/memex".to_string(),
4097 embedding_provider: "ollama-local".to_string(),
4098 total_documents: 42,
4099 namespace_count: 2,
4100 namespaces: vec![
4101 DiscoveryNamespaceInfo {
4102 id: "alpha".to_string(),
4103 count: 30,
4104 last_indexed_at: Some("2026-04-10T17:00:00Z".to_string()),
4105 },
4106 DiscoveryNamespaceInfo {
4107 id: "beta".to_string(),
4108 count: 12,
4109 last_indexed_at: None,
4110 },
4111 ],
4112 };
4113
4114 let namespaces = namespaces_response_from_discovery(&discovery);
4115 let overview = overview_response_from_discovery(&discovery);
4116 let status = status_response_from_discovery(&discovery);
4117
4118 assert_eq!(namespaces.total, 2);
4119 assert_eq!(namespaces.namespaces[0].name, "alpha");
4120 assert_eq!(namespaces.namespaces[1].count, 12);
4121
4122 assert_eq!(overview.namespace_count, 2);
4123 assert_eq!(overview.total_documents, 42);
4124 assert_eq!(overview.db_path, "/tmp/memex");
4125
4126 assert!(status["cache_ready"].as_bool().unwrap());
4127 assert_eq!(status["namespace_count"], 2);
4128 assert_eq!(status["hint"], "OK");
4129 }
4130
4131 #[tokio::test]
4132 async fn test_discovery_refreshes_namespace_inventory_after_external_write() {
4133 let tmp = tempfile::tempdir().expect("tempdir");
4134 let db_path = tmp.path().join(".lancedb");
4135 let db_path_str = db_path.to_string_lossy().to_string();
4136 let state = build_test_http_state(&db_path_str).await;
4137 let external_storage = StorageManager::new(&db_path_str)
4138 .await
4139 .expect("external storage");
4140
4141 write_namespace_doc(&external_storage, "alpha", "alpha-1").await;
4142 let first = build_discovery_response(&state).await;
4143 assert_eq!(first.status, "ok");
4144 assert_eq!(first.namespace_count, 1);
4145 assert_eq!(first.namespaces[0].id, "alpha");
4146
4147 write_namespace_doc(&external_storage, "beta", "beta-1").await;
4148 let second = build_discovery_response(&state).await;
4149 let namespace_ids: Vec<_> = second.namespaces.iter().map(|ns| ns.id.as_str()).collect();
4150
4151 assert_eq!(second.status, "ok");
4152 assert_eq!(second.namespace_count, 2);
4153 assert_eq!(namespace_ids, vec!["alpha", "beta"]);
4154 }
4155
4156 #[tokio::test]
4157 async fn test_context_pack_route_rebuilds_clustered_source_context() {
4158 let tmp = tempfile::tempdir().expect("tempdir");
4159 let db_path = tmp.path().join(".lancedb");
4160 let db_path_str = db_path.to_string_lossy().to_string();
4161 let state = build_test_http_state(&db_path_str).await;
4162 let storage = state.rag.storage_manager();
4163 let path = "/tmp/codex__019d749e-5b30-7f33-8bb4-a3a6e21b66c4__clean.md";
4164
4165 let mut outer = ChromaDocument::new_flat_with_hashes(
4166 "outer-hit".to_string(),
4167 "kb:context".to_string(),
4168 vec![0.5, 0.25],
4169 json!({"path": path, "source_path": path}),
4170 "Outer summary about a release decision.".to_string(),
4171 "chunk-outer".to_string(),
4172 Some("source-shared".to_string()),
4173 );
4174 outer.layer = SliceLayer::Outer.as_u8();
4175
4176 let mut core = ChromaDocument::new_flat_with_hashes(
4177 "core-source".to_string(),
4178 "kb:context".to_string(),
4179 vec![0.5, 0.25],
4180 json!({"path": path, "source_path": path}),
4181 "# Full Transcript\n\nDecision: ship the context-pack route.".to_string(),
4182 "chunk-core".to_string(),
4183 Some("source-shared".to_string()),
4184 );
4185 core.layer = SliceLayer::Core.as_u8();
4186
4187 storage
4188 .add_to_store(vec![outer, core])
4189 .await
4190 .expect("seed context docs");
4191
4192 let app = create_router(state, &HttpServerConfig::default());
4193 let response = app
4194 .oneshot(
4195 Request::builder()
4196 .method(Method::POST)
4197 .uri("/api/context-pack")
4198 .header(header::CONTENT_TYPE, "application/json")
4199 .body(Body::from(
4200 json!({
4201 "namespace": "kb:context",
4202 "ids": ["outer-hit"],
4203 "view": "full"
4204 })
4205 .to_string(),
4206 ))
4207 .unwrap(),
4208 )
4209 .await
4210 .unwrap();
4211
4212 assert_eq!(response.status(), StatusCode::OK);
4213 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
4214 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
4215
4216 assert_eq!(body["selected_ids"], json!(["outer-hit"]));
4217 assert_eq!(body["duplicate_count"], 0);
4218 assert_eq!(body["clusters"].as_array().unwrap().len(), 1);
4219 assert_eq!(
4220 body["sources"][0]["status"], "rebuilt_from_core_chunk",
4221 "{body}"
4222 );
4223 assert!(
4224 body["markdown"]
4225 .as_str()
4226 .unwrap()
4227 .contains("Decision: ship the context-pack route."),
4228 "{body}"
4229 );
4230 }
4231
4232 #[test]
4233 fn test_chroma_document_maps_to_browse_json() {
4234 let doc = ChromaDocument {
4235 id: "outer-1".to_string(),
4236 namespace: "memories".to_string(),
4237 embedding: vec![],
4238 metadata: json!({"kind": "note"}),
4239 document: "hello".to_string(),
4240 layer: SliceLayer::Outer.as_u8(),
4241 parent_id: Some("root-1".to_string()),
4242 children_ids: vec!["child-1".to_string()],
4243 keywords: vec!["hello".to_string()],
4244 content_hash: None,
4245 source_hash: None,
4246 };
4247
4248 let json_doc: SearchResultJson = doc.into();
4249
4250 assert_eq!(json_doc.id, "outer-1");
4251 assert_eq!(json_doc.namespace, "memories");
4252 assert_eq!(json_doc.text, "hello");
4253 assert_eq!(json_doc.layer.as_deref(), Some(SliceLayer::Outer.name()));
4254 assert!(json_doc.can_expand);
4255 assert!(json_doc.can_drill_up);
4256 }
4257
4258 #[test]
4263 fn test_constant_time_token_comparison() {
4264 assert!(token_matches("secret123", "secret123"));
4265 assert!(!token_matches("secret123", "secret124"));
4266 assert!(!token_matches("short", "longer_token"));
4267 assert!(!token_matches("", "notempty"));
4268 assert!(token_matches("", ""));
4269 }
4270
4271 #[test]
4272 fn test_auth_mode_parse() {
4273 assert_eq!(AuthMode::parse("mutating-only"), AuthMode::MutatingOnly);
4274 assert_eq!(AuthMode::parse("all-routes"), AuthMode::AllRoutes);
4275 assert_eq!(AuthMode::parse("namespace-acl"), AuthMode::NamespaceAcl);
4276 assert_eq!(AuthMode::parse("unknown"), AuthMode::MutatingOnly);
4277 assert_eq!(AuthMode::parse(""), AuthMode::MutatingOnly);
4278 }
4279
4280 #[test]
4281 fn test_cors_wildcard_produces_any() {
4282 let config = HttpServerConfig {
4284 cors_origins: vec!["*".to_string()],
4285 bind_address: std::net::Ipv4Addr::new(192, 168, 1, 1).into(),
4286 ..Default::default()
4287 };
4288 let state = build_test_http_state_sync();
4290 let _router = create_router(state, &config);
4291 }
4292
4293 fn build_test_http_state_sync() -> HttpState {
4294 let rt = tokio::runtime::Builder::new_current_thread()
4296 .enable_all()
4297 .build()
4298 .unwrap();
4299 rt.block_on(async {
4300 let tmp = tempfile::tempdir().expect("tempdir");
4301 let db_path = tmp.path().join(".lancedb");
4302 build_test_http_state(db_path.to_str().unwrap()).await
4303 })
4304 }
4305
4306 fn build_test_dashboard_oidc_runtime() -> Arc<DashboardOidcRuntime> {
4307 Arc::new(DashboardOidcRuntime::new(ResolvedDashboardOidcConfig {
4308 issuer_url: "https://issuer.example".to_string(),
4309 client_id: "rust-memex-dashboard".to_string(),
4310 client_secret: None,
4311 public_base_url: "https://dashboard.example".to_string(),
4312 redirect_url: "https://dashboard.example/auth/callback".to_string(),
4313 scopes: vec!["openid".to_string(), "profile".to_string()],
4314 secure_cookie: true,
4315 }))
4316 }
4317
4318 #[tokio::test]
4319 async fn test_all_routes_auth_requires_health_and_mcp() {
4320 let tmp = tempfile::tempdir().expect("tempdir");
4321 let db_path = tmp.path().join(".lancedb");
4322 let db_path_str = db_path.to_string_lossy().to_string();
4323 let state = build_test_http_state(&db_path_str).await;
4324
4325 let app = create_router(
4326 state,
4327 &HttpServerConfig {
4328 auth_token: Some("secret".to_string()),
4329 auth_mode: AuthMode::AllRoutes,
4330 ..HttpServerConfig::default()
4331 },
4332 );
4333
4334 let health = app
4335 .clone()
4336 .oneshot(
4337 Request::builder()
4338 .uri("/health")
4339 .body(Body::empty())
4340 .unwrap(),
4341 )
4342 .await
4343 .unwrap();
4344 assert_eq!(health.status(), StatusCode::UNAUTHORIZED);
4345
4346 let discovery = app
4347 .clone()
4348 .oneshot(
4349 Request::builder()
4350 .uri("/api/discovery")
4351 .body(Body::empty())
4352 .unwrap(),
4353 )
4354 .await
4355 .unwrap();
4356 assert_eq!(discovery.status(), StatusCode::UNAUTHORIZED);
4357
4358 let mcp = app
4359 .clone()
4360 .oneshot(Request::builder().uri("/mcp/").body(Body::empty()).unwrap())
4361 .await
4362 .unwrap();
4363 assert_eq!(mcp.status(), StatusCode::UNAUTHORIZED);
4364 }
4365
4366 #[tokio::test]
4367 async fn test_all_routes_root_returns_login_page_with_401() {
4368 let tmp = tempfile::tempdir().expect("tempdir");
4369 let db_path = tmp.path().join(".lancedb");
4370 let db_path_str = db_path.to_string_lossy().to_string();
4371 let state = build_test_http_state(&db_path_str).await;
4372
4373 let app = create_router(
4374 state,
4375 &HttpServerConfig {
4376 auth_token: Some("secret".to_string()),
4377 auth_mode: AuthMode::AllRoutes,
4378 ..HttpServerConfig::default()
4379 },
4380 );
4381
4382 let response = app
4383 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4384 .await
4385 .unwrap();
4386 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4387 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
4388 let body_text = String::from_utf8(body.to_vec()).unwrap();
4389 assert!(body_text.contains("memex_token"));
4390 }
4391
4392 #[tokio::test]
4393 async fn test_all_routes_accepts_query_token_for_get_routes() {
4394 let tmp = tempfile::tempdir().expect("tempdir");
4395 let db_path = tmp.path().join(".lancedb");
4396 let db_path_str = db_path.to_string_lossy().to_string();
4397 let state = build_test_http_state(&db_path_str).await;
4398
4399 let app = create_router(
4400 state,
4401 &HttpServerConfig {
4402 auth_token: Some("secret".to_string()),
4403 auth_mode: AuthMode::AllRoutes,
4404 allow_query_token: true,
4405 ..HttpServerConfig::default()
4406 },
4407 );
4408
4409 let response = app
4410 .oneshot(
4411 Request::builder()
4412 .uri("/api/discovery?token=secret")
4413 .body(Body::empty())
4414 .unwrap(),
4415 )
4416 .await
4417 .unwrap();
4418 assert_eq!(response.status(), StatusCode::OK);
4419 }
4420
4421 #[tokio::test]
4422 async fn test_oidc_root_redirects_to_login_when_session_missing() {
4423 let tmp = tempfile::tempdir().expect("tempdir");
4424 let db_path = tmp.path().join(".lancedb");
4425 let db_path_str = db_path.to_string_lossy().to_string();
4426 let mut state = build_test_http_state(&db_path_str).await;
4427 state.dashboard_oidc = Some(build_test_dashboard_oidc_runtime());
4428
4429 let app = create_router(
4430 state,
4431 &HttpServerConfig {
4432 auth_token: Some("secret".to_string()),
4433 auth_mode: AuthMode::AllRoutes,
4434 ..HttpServerConfig::default()
4435 },
4436 );
4437
4438 let response = app
4439 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4440 .await
4441 .unwrap();
4442 assert_eq!(response.status(), StatusCode::SEE_OTHER);
4443 assert_eq!(
4444 response.headers().get(header::LOCATION).unwrap(),
4445 "/auth/login"
4446 );
4447 }
4448
4449 #[tokio::test]
4450 async fn test_dashboard_session_opens_dashboard_routes_but_not_mcp() {
4451 let tmp = tempfile::tempdir().expect("tempdir");
4452 let db_path = tmp.path().join(".lancedb");
4453 let db_path_str = db_path.to_string_lossy().to_string();
4454 let mut state = build_test_http_state(&db_path_str).await;
4455 let oidc = build_test_dashboard_oidc_runtime();
4456 oidc.sessions.write().await.insert(
4457 "session-123".to_string(),
4458 DashboardSession {
4459 subject: "user-1".to_string(),
4460 created_at: Instant::now(),
4461 },
4462 );
4463 state.dashboard_oidc = Some(oidc);
4464
4465 let app = create_router(
4466 state,
4467 &HttpServerConfig {
4468 auth_token: Some("secret".to_string()),
4469 auth_mode: AuthMode::AllRoutes,
4470 ..HttpServerConfig::default()
4471 },
4472 );
4473
4474 let discovery = app
4475 .clone()
4476 .oneshot(
4477 Request::builder()
4478 .uri("/api/discovery")
4479 .header(header::COOKIE, "rust_memex_dashboard_session=session-123")
4480 .body(Body::empty())
4481 .unwrap(),
4482 )
4483 .await
4484 .unwrap();
4485 assert_eq!(discovery.status(), StatusCode::OK);
4486
4487 let search = app
4488 .clone()
4489 .oneshot(
4490 Request::builder()
4491 .method(Method::POST)
4492 .uri("/search")
4493 .header(header::COOKIE, "rust_memex_dashboard_session=session-123")
4494 .header(header::CONTENT_TYPE, "application/json")
4495 .body(Body::from(r#"{"query":"hello"}"#))
4496 .unwrap(),
4497 )
4498 .await
4499 .unwrap();
4500 assert_ne!(search.status(), StatusCode::UNAUTHORIZED);
4501 assert_ne!(search.status(), StatusCode::FORBIDDEN);
4502
4503 let mcp = app
4504 .oneshot(
4505 Request::builder()
4506 .uri("/mcp/")
4507 .header(header::COOKIE, "rust_memex_dashboard_session=session-123")
4508 .body(Body::empty())
4509 .unwrap(),
4510 )
4511 .await
4512 .unwrap();
4513 assert_eq!(mcp.status(), StatusCode::UNAUTHORIZED);
4514 }
4515}