Skip to main content

rust_memex/http/
mod.rs

1//! HTTP/SSE server for rust-memex
2//!
3//! Provides HTTP endpoints for agents that can't hold LanceDB lock directly.
4//! All database access goes through the single server instance.
5//!
6//! Uses RAGPipeline (same as MCPServer) for consistency and full feature support:
7//! - Multi-namespace (each agent can have own namespace)
8//! - Onion slices (expand/drill-down in SSE)
9//! - Full indexing pipeline with dedup
10//!
11//! Endpoints:
12//! - GET  /                  - HTML Dashboard (browse memories visually)
13//! - GET  /api/discovery     - Endpoint discovery: status, db info, namespaces (canonical)
14//! - GET  /api/namespaces    - List all namespaces with counts
15//! - GET  /api/overview      - Database overview/stats
16//! - GET  /api/browse/:ns    - Browse documents in namespace
17//! - GET  /health            - Health check
18//! - POST /search            - Search documents
19//! - GET  /sse/search        - SSE streaming search
20//! - GET  /sse/namespaces    - SSE streaming namespace listing with summaries
21//! - POST /sse/compact       - SSE streaming database compaction
22//! - POST /sse/cleanup       - SSE streaming version cleanup (>7 days)
23//! - POST /sse/gc            - SSE streaming orphan garbage collection
24//! - POST /sse/optimize      - SSE streaming database optimize (compact + prune)
25//! - POST /sse/reprocess     - SSE streaming namespace rebuild from JSONL
26//! - POST /sse/reindex       - SSE streaming namespace rebuild from namespace source
27//! - POST /upsert            - Upsert document (memory_upsert)
28//! - POST /index             - Index text with full pipeline
29//! - POST /api/merge         - Merge multiple LanceDB stores into one target
30//! - POST /api/repair-writes - Inspect or repair cross-store recovery ledgers
31//! - POST /api/export        - Stream a namespace as JSONL
32//! - POST /api/import        - Import JSONL into a namespace
33//! - POST /api/migrate-namespace - Atomically rename a namespace
34//! - GET  /expand/:ns/:id    - Expand onion slice (get children)
35//! - GET  /parent/:ns/:id    - Get parent slice (drill up)
36//! - DELETE /ns/:namespace   - Purge namespace
37//!
38//! MCP-over-SSE endpoints (for Claude Code compatibility):
39//! - GET  /mcp/             - SSE stream for MCP messages (sends endpoint event)
40//! - POST /mcp/messages/    - JSON-RPC POST endpoint with session_id
41//!
42//! Vibecrafted with AI Agents by Loctree (c)2026 Loctree
43
44mod 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
96// ============================================================================
97// HTML Dashboard (embedded)
98// ============================================================================
99
100/// Embedded HTML dashboard for browsing memories visually
101const 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 &copy;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()">&times;</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, "&#39;")})'>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
907/// Get dashboard HTML with version injected
908fn get_dashboard_html() -> String {
909    DASHBOARD_HTML.replace("{VERSION}", env!("CARGO_PKG_VERSION"))
910}
911
912// ============================================================================
913// API Response Types for Dashboard
914// ============================================================================
915
916/// Namespace info for API
917#[derive(Debug, Clone, Serialize)]
918pub struct NamespaceInfo {
919    pub name: String,
920    pub count: usize,
921}
922
923/// Namespaces list response
924#[derive(Debug, Serialize)]
925pub struct NamespacesResponse {
926    pub namespaces: Vec<NamespaceInfo>,
927    pub total: usize,
928}
929
930/// Overview response
931#[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/// Canonical discovery namespace entry.
940#[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/// Canonical discovery response for dashboards and HTTP clients.
949#[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/// Browse query params
962#[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/// Browse response
975#[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
983/// MCP session for SSE connections
984pub struct McpSession {
985    /// Session ID
986    pub id: String,
987    /// Channel to send responses back to SSE stream
988    pub tx: broadcast::Sender<serde_json::Value>,
989    /// Created timestamp
990    pub created: std::time::Instant,
991}
992
993/// MCP session manager
994pub 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    /// Create new session and return session ID
1006    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    /// Get session by ID
1019    pub async fn get_session(&self, id: &str) -> Option<Arc<McpSession>> {
1020        self.sessions.read().await.get(id).cloned()
1021    }
1022
1023    /// Remove session
1024    pub async fn remove_session(&self, id: &str) {
1025        self.sessions.write().await.remove(id);
1026    }
1027
1028    /// Cleanup old sessions (older than 1 hour)
1029    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/// Shared state for HTTP handlers - reuses the same MCP core and storage runtime as stdio/SSE.
1042#[derive(Clone)]
1043pub struct HttpState {
1044    pub rag: Arc<RAGPipeline>,
1045    /// Shared MCP protocol core reused by stdio and HTTP/SSE transports
1046    pub mcp_core: Arc<McpCore>,
1047    /// MCP session manager for SSE transport
1048    pub mcp_sessions: Arc<McpSessionManager>,
1049    /// Base URL for MCP messages endpoint (set at startup)
1050    pub mcp_base_url: Arc<RwLock<String>>,
1051    /// Cached namespace list (refreshed in background for large DBs)
1052    pub cached_namespaces: Arc<RwLock<Option<Vec<NamespaceInfo>>>>,
1053    /// Per-namespace last activity timestamp (updated on upsert/index)
1054    pub namespace_activity: Arc<RwLock<HashMap<String, String>>>,
1055    /// Last successful append timestamp across all HTTP write paths.
1056    pub last_successful_append_at: Arc<RwLock<Option<String>>>,
1057    /// Recent destructive diagnostic dry-runs approved for follow-up execute calls.
1058    pub diagnostic_dry_run_approvals: Arc<RwLock<HashMap<String, Instant>>>,
1059    /// Optional Bearer token for authenticating mutating requests
1060    pub auth_token: Option<String>,
1061    /// Auth enforcement mode
1062    pub auth_mode: AuthMode,
1063    /// Allow ?token= query parameter on read GETs
1064    pub allow_query_token: bool,
1065    /// Multi-token auth manager (Track C). Used when auth_mode == NamespaceAcl.
1066    pub auth_manager: Option<Arc<crate::auth::AuthManager>>,
1067    /// Optional dashboard-only OIDC runtime for browser sessions.
1068    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/// Search request body
1321#[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    /// Optional layer filter for onion slices
1329    #[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/// Search result for JSON response
1344#[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    /// Can expand to children (has children_ids)
1360    pub can_expand: bool,
1361    /// Can drill up to parent (has parent_id)
1362    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/// Search response
1429#[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/// Upsert request body (memory_upsert)
1441#[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/// Index text request (full pipeline)
1451#[derive(Debug, Deserialize)]
1452pub struct IndexRequest {
1453    pub namespace: String,
1454    pub content: String,
1455    /// Slice mode: "flat", "outer", "deep" (default: "flat")
1456    #[serde(default = "default_slice_mode")]
1457    pub slice_mode: String,
1458}
1459
1460fn default_slice_mode() -> String {
1461    "flat".to_string()
1462}
1463
1464/// SSE search query params
1465#[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/// Cross-search request - search across all namespaces
1483#[derive(Debug, Deserialize)]
1484pub struct CrossSearchRequest {
1485    pub query: String,
1486    /// Limit per namespace (default: 5)
1487    #[serde(default = "default_cross_limit")]
1488    pub limit: usize,
1489    /// Total limit across all namespaces (default: 20)
1490    #[serde(default = "default_total_limit")]
1491    pub total_limit: usize,
1492    /// Search mode: "vector", "keyword"/"bm25", "hybrid" (default: hybrid)
1493    #[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    /// Strategy for grouping chunks into duplicate sets. Defaults to
1546    /// `source-hash-layer` (post-v4 default). Accepts `source-hash-layer`,
1547    /// `source-hash`, or `content-hash` (legacy). Spec P4.
1548    #[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    /// Optional namespace filter. Omitted = backfill all namespaces.
1585    #[serde(default)]
1586    pub ns: Option<String>,
1587    /// Default `false` (dry run). Caller must opt in to writes.
1588    #[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/// Cross-search query params for GET endpoint
1653#[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/// Cross-search response
1666#[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/// Health check response
1679#[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
1699/// Extract bearer token from Authorization header or ?token= query param.
1700fn extract_bearer_token(request: &Request, allow_query_token: bool) -> Option<String> {
1701    // 1. Check Authorization header first
1702    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    // 2. Check ?token= query param if allowed
1712    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
1723/// Constant-time token comparison to prevent timing attacks.
1724fn 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
1812/// Bearer token auth middleware for mutating endpoints.
1813/// If the server has an auth_token configured, requires `Authorization: Bearer <token>`.
1814/// Uses constant-time comparison to prevent timing side-channel attacks.
1815/// Returns 401 if the token is missing or doesn't match.
1816///
1817/// In NamespaceAcl mode (Track C), delegates to AuthManager for multi-token
1818/// lookup with scope enforcement. The scope is inferred from the HTTP method:
1819/// GET/HEAD = Read, POST/PUT/DELETE = Write.
1820async 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    // Track C: NamespaceAcl mode uses the AuthManager for multi-token auth
1847    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        // Determine required scope from method
1860        let required_scope = match *request.method() {
1861            Method::GET | Method::HEAD => crate::auth::Scope::Read,
1862            _ => crate::auth::Scope::Write,
1863        };
1864
1865        // Extract namespace from path if present (e.g., /api/browse/{ns}, /ns/{namespace})
1866        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    // Fallback: NamespaceAcl mode without AuthManager configured = same as legacy
1899
1900    // Legacy single-token path
1901    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
1913/// Extract namespace from URL path segments for ACL checks.
1914/// Recognizes patterns like:
1915///   /api/browse/{ns}  /ns/{namespace}  /expand/{ns}/{id}  /get/{ns}/{id}
1916///   /delete/{ns}/{id}  /parent/{ns}/{id}
1917fn 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}
1921        ["api", "browse", ns] => Some(ns.to_string()),
1922        // /ns/{namespace}
1923        ["ns", ns] => Some(ns.to_string()),
1924        // /expand/{ns}/{id}, /parent/{ns}/{id}, /get/{ns}/{id}, /delete/{ns}/{id}
1925        [verb, ns, _id] if matches!(*verb, "expand" | "parent" | "get" | "delete") => {
1926            Some(ns.to_string())
1927        }
1928        _ => None,
1929    }
1930}
1931
1932/// Auth enforcement mode for HTTP endpoints.
1933#[derive(Clone, Debug, PartialEq, Eq)]
1934pub enum AuthMode {
1935    /// Bearer required only on mutating + MCP routes (default, backwards compat)
1936    MutatingOnly,
1937    /// Bearer required on ALL routes
1938    AllRoutes,
1939    /// Reserved for Track C namespace-level ACL
1940    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/// HTTP server configuration passed to `create_router` and `start_server`
2151#[derive(Clone)]
2152pub struct HttpServerConfig {
2153    /// Bearer token for auth on HTTP endpoints. None = no auth.
2154    pub auth_token: Option<String>,
2155    /// Optional dashboard-only OIDC configuration.
2156    pub dashboard_oidc: Option<DashboardOidcConfig>,
2157    /// Allowed CORS origins. Empty = same-origin only (unless localhost).
2158    pub cors_origins: Vec<String>,
2159    /// Bind address. Defaults to 127.0.0.1.
2160    pub bind_address: IpAddr,
2161    /// Auth enforcement mode
2162    pub auth_mode: AuthMode,
2163    /// Allow ?token= query param on read GETs (only in all-routes mode)
2164    pub allow_query_token: bool,
2165    /// Multi-token auth manager (Track C). Used when auth_mode == NamespaceAcl.
2166    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
2183/// Create the HTTP router
2184pub 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    // CORS policy: permissive on localhost, restrictive otherwise
2194    let cors = if is_localhost && config.cors_origins.is_empty() {
2195        // Localhost with no explicit origins: permissive (safe since local only)
2196        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        // Non-localhost with no explicit origins: restrict to GET/POST, same-origin
2202        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        // Explicit wildcard: use tower_http::cors::Any instead of literal "*" string
2210        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        // Explicit origins configured
2216        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    // Read-only routes: public in mutating-only mode, authed in all-routes mode.
2233    // The dashboard route returns an auth bootstrap page when bearer is missing.
2234    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    // Conditionally wrap read routes with auth middleware in all-routes mode
2259    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    // Mutating routes (auth required when token is configured)
2269    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    // MCP-over-SSE endpoints (auth required when token is configured)
2284    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
2330/// Health check endpoint
2331async 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// ============================================================================
2413// Dashboard & Browse API Handlers
2414// ============================================================================
2415
2416#[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
2542/// Dashboard HTML endpoint (GET /)
2543/// Minimal HTML login form for dashboard auth in all-routes mode.
2544/// Token is stored in localStorage and used as Bearer header for all API calls.
2545const 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    // In all-routes mode without OIDC, check if the user has a valid bearer token.
2595    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
2613/// List all namespaces with document counts (GET /api/namespaces)
2614async fn namespaces_handler(State(state): State<HttpState>) -> Json<NamespacesResponse> {
2615    Json(namespaces_response_from_discovery(
2616        &build_discovery_response(&state).await,
2617    ))
2618}
2619
2620/// Database overview (GET /api/overview)
2621async fn overview_handler(State(state): State<HttpState>) -> Json<OverviewResponse> {
2622    Json(overview_response_from_discovery(
2623        &build_discovery_response(&state).await,
2624    ))
2625}
2626
2627/// System status including cache state (GET /api/status)
2628async 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
2634/// Diagnostic audit for a single namespace (GET /api/audit)
2635async 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
2671/// Database statistics (GET /api/stats)
2672async 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
2681/// Namespace statistics (GET /api/stats/{ns})
2682async 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
2696/// Time-bucketed timeline (GET /api/timeline)
2697async 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
2727/// Purge low-quality namespaces, gated by prior dry-run unless explicitly single-step.
2728async 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
2767/// Deduplicate a namespace, dry-run by default and gated on execute.
2768async 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
2826/// Backfill `content_hash` (per-chunk) and `source_hash` (per-source) for
2827/// chunks written before the v4 schema. Behaves like `dedup`: dry-run by
2828/// default, requires explicit confirmation for the destructive path. Spec:
2829/// 2026-04-27 onion-slicer fix, P0 backfill.
2830async 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
2879/// Browse documents in namespace (GET /api/browse/:ns)
2880async 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
2918/// Browse all documents (no namespace filter) (GET /api/browse)
2919async 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
2950/// Refresh endpoint - clears LanceDB cache to see new data from other processes
2951async 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
2967/// Search endpoint (POST /search)
2968async 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
3015/// SSE streaming search endpoint (GET /sse/search?query=...&namespace=...&limit=...)
3016async 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        // Send start event
3022        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            &params.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                    // Small delay for streaming effect
3068                    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
3093/// Cross-search endpoint (GET /cross-search?q=...&limit=...&total_limit=...&mode=...)
3094/// Searches across ALL namespaces, merges results by score
3095async 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    // Search each namespace
3121    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            &params.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                // Log but continue - don't fail entire search for one namespace
3142                error!("Cross-search error in namespace '{}': {}", ns, e);
3143            }
3144        }
3145    }
3146
3147    // Sort by score descending
3148    all_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3149
3150    // Truncate to total_limit
3151    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
3170/// SSE streaming cross-search endpoint (GET /sse/cross-search?q=...&limit=...&total_limit=...)
3171/// Streams results as they come from each namespace
3172async 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        // Send start event
3178        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        // Get all namespaces
3188        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        // Send namespace info
3200        yield Ok(Event::default()
3201            .event("namespaces")
3202            .data(serde_json::json!({
3203                "count": namespaces.len(),
3204                "namespaces": namespaces
3205            }).to_string()));
3206
3207        // Collect all results with scores for final ranking
3208        let mut all_results: Vec<(SearchResultJson, f32, String)> = Vec::new();
3209
3210        // Search each namespace and stream intermediate results
3211        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                &params.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            // Small delay between namespaces
3249            tokio::time::sleep(Duration::from_millis(5)).await;
3250        }
3251
3252        // Sort all results by score descending
3253        all_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
3254
3255        // Truncate and stream final ranked results
3256        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
3284/// Minimal endpoint discovery — single source of truth for clients and dashboards
3285/// GET /api/discovery
3286///
3287/// Returns status, db info, and all namespaces with counts and last activity.
3288/// Replaces fragmented /api/namespaces + /api/overview + /api/status trio.
3289fn 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
3301/// SSE streaming namespace listing with per-namespace summary
3302/// GET /sse/namespaces - streams each namespace with doc count, layer distribution, keywords
3303async 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        // Get namespace list
3316        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        // Stream per-namespace summary
3337        for (i, (ns_name, doc_count)) in namespaces.iter().enumerate() {
3338            // Get documents for this namespace to compute layer distribution + keywords
3339            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
3390/// Upsert document endpoint (POST /upsert) - uses memory_upsert
3391async 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
3424/// Index text with full pipeline (POST /index)
3425async 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    // Generate ID from content hash
3445    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
3522/// Expand onion slice - get children (GET /expand/:ns/:id)
3523async 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
3542/// Get parent slice - drill up (GET /parent/:ns/:id)
3543async 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
3564/// Get document by namespace and ID (GET /get/:ns/:id)
3565async 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
3585/// Delete document (POST /delete/:ns/:id)
3586async 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
3610/// Purge entire namespace (DELETE /ns/:namespace)
3611async 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// ============================================================================
3635// MCP-over-SSE Transport Handlers
3636// ============================================================================
3637
3638/// Query params for MCP messages endpoint
3639#[derive(Debug, Deserialize)]
3640pub struct McpMessagesParams {
3641    pub session_id: Option<String>,
3642}
3643
3644/// MCP SSE endpoint - GET /sse/ or /mcp/
3645/// Creates a new session and sends the endpoint URL for messages
3646async fn mcp_sse_handler(
3647    State(state): State<HttpState>,
3648    headers: axum::http::HeaderMap,
3649) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
3650    // Create a new session
3651    let (session_id, mut rx) = state.mcp_sessions.create_session().await;
3652
3653    // Use Host header from request to build endpoint URL (enables remote access)
3654    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        // First event: tell client where to POST messages (FastMCP/MCP SSE protocol)
3674        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        // Keep connection alive and forward responses from the session
3680        loop {
3681            tokio::select! {
3682                // Receive responses from session channel
3683                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                // Keep-alive ping every 30 seconds
3702                _ = tokio::time::sleep(Duration::from_secs(30)) => {
3703                    // SSE keepalive is handled by axum's KeepAlive
3704                }
3705            }
3706        }
3707
3708        // Clean up session when SSE stream drops (client disconnect)
3709        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
3720/// MCP Messages endpoint - POST /messages/?session_id=xxx
3721/// Receives JSON-RPC requests and sends responses via SSE stream
3722/// Returns 202 Accepted - actual response delivered via SSE
3723async 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    // Get the session
3736    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    // Return 202 Accepted - actual response (if any) goes via SSE stream
3768    Ok(StatusCode::ACCEPTED)
3769}
3770
3771/// Start the HTTP server with shared MCP core.
3772pub 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    // Fallback base_url - actual URL is derived from Host header in mcp_sse_handler
3779    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    // Log auth status
3789    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    // Log namespace security status. Track C: auth manager is always present;
3812    // "enabled" means at least one token is configured.
3813    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                // Pick any token that references this namespace for its description.
3832                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    // Spawn background task to refresh namespace cache every 5 minutes
3859    let bg_rag = rag.clone();
3860    let bg_cache = cached_namespaces.clone();
3861    tokio::spawn(async move {
3862        // Initial load (with longer timeout for startup)
3863        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                // Database error (likely "too many open files" - needs optimize)
3880                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        // Refresh every 5 minutes
3891        let mut interval = tokio::time::interval(Duration::from_secs(300));
3892        interval.tick().await; // Skip first immediate tick
3893
3894        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    // Spawn background task to reap stale MCP sessions every 5 minutes
3925    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; // skip first immediate tick
3930        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        // Track C: AuthManager with a sibling token store path so that any
3973        // create_token call during these HTTP tests can persist without
3974        // erroring on an empty path. The legacy default (security disabled)
3975        // is preserved by not pre-seeding any tokens.
3976        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    // ====================================================================
4259    // Auth validation tests (Track A + Track B)
4260    // ====================================================================
4261
4262    #[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        // When cors_origins contains "*", the CORS layer should use Any
4283        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        // Verify the config triggers the wildcard branch (no panic = correct path)
4289        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        // Minimal state for router creation tests (no DB needed)
4295        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}