Skip to content

Commit 77fd4d9

Browse files
authored
fix(web): workspace-scope dashboard endpoints + zero test coverage + hardening (#346)
The web dashboard (src/web/mod.rs, dashboard.html) had no test coverage and three of its four data-fetching paths (list_entities, search, graph) ignored workspace_hash entirely -- the same information-disclosure class already fixed for context()/recall_when()/prepare in #338. In a federated (multi-workspace) vault, the dashboard showed every workspace's memories, including cross-workspace entity-graph edges, in one unfiltered view. Fixes: - db::list_entities/get_entity_graph gain workspace_hash: Option<&str>, same exact-match scoping recall()/context() use since v1.2.0. None preserves prior unscoped behavior (backward compatible). - New db::count_entities(): a real COUNT(*) with the same filters, no LIMIT/OFFSET -- fixes the dashboard's list_entities/search 'total' field, which was actually 'items in this page' (items.len()), making client-side pagination (has-more-pages?) impossible to implement correctly. list_entities now reports a true total; search's total remains page-count (documented why: FTS5 relevance ranking has no cheap unlimited-count backing query without doubling recall cost). - get_entity_graph drops edges whose target falls outside the scoped node set, rather than emitting a dangling edge to a node the caller never receives (which would also leak the target id's existence across workspaces). - web/mod.rs: list_entities/search/graph endpoints accept an optional ?workspace= query param, threaded through to the new db.rs params. journal/get_recent_journal is NOT scoped -- the journal table has no workspace_hash column; fixing that needs a schema migration and is documented as a follow-up rather than folded into this pass. - Injection audit: verified every dashboard.html render site that interpolates untrusted entity content (key, category, layer, body_json via JSON.stringify, timeline event fields) already goes through the esc() HTML-escaping helper -- no missing site found, no code change needed here, confirmed by inspection given the sibling <memory-prep> injection fix in #337. - Cosmetic: dashboard <title>/<h1> said 'Mneme', not updated in the #341 Perseus Vault rebrand -- fixed. - Hardening: vis-network@9.1.6 CDN <script> tag gains a real SRI integrity hash (computed from the fetched artifact, sha384) + crossorigin="anonymous", closing a supply-chain gap for any deployment that binds --web-bind beyond 127.0.0.1. Tests: 12 new tests in src/web/mod.rs (zero before this change) using Axum's tower::ServiceExt::oneshot -- auth middleware (mirrors the existing gRPC transport auth test pattern), list_entities/search/graph workspace scoping (with and without ?workspace=), true-total pagination, entity_detail 404, stats/journal smoke tests. Full suite: 167 passed, 0 failed, 2 ignored (was 155/0/2 before this change), stable across 3 repeated runs. Manually verified end-to-end against the compiled binary's live --web server: seeded two workspaces via real MCP mimir_remember calls, confirmed unscoped /api/entities shows both (backward compat), scoped ?workspace= isolates correctly for entities/search/graph, cross- workspace graph edges are dropped, dashboard title/h1/SRI hash render as expected in the served HTML. Version bump 2.12.0 -> 2.13.0.
1 parent a788cc9 commit 77fd4d9

5 files changed

Lines changed: 519 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "perseus-vault"
3-
version = "2.12.0"
3+
version = "2.13.0"
44
edition = "2021"
55
description = "Persistent memory engine for AI agents — MCP JSON-RPC stdio server (formerly Mneme/Mimir)"
66
repository = "https://github.com/Perseus-Computing-LLC/perseus-vault"

src/db.rs

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3347,12 +3347,17 @@ impl Database {
33473347
}
33483348

33493349
/// List entities with pagination and optional filters.
3350+
/// `workspace_hash`: when `Some(non-empty)`, only entities with a matching
3351+
/// workspace_hash are returned — same exact-match scoping `recall()` and
3352+
/// `context()` use (#338/#343). Without it, the web dashboard's entity
3353+
/// list leaked every workspace's memory into one view.
33503354
pub fn list_entities(
33513355
&self,
33523356
offset: i64,
33533357
limit: i64,
33543358
category: Option<&str>,
33553359
layer: Option<&str>,
3360+
workspace_hash: Option<&str>,
33563361
) -> Result<Vec<Entity>, Box<dyn std::error::Error>> {
33573362
let conn = self.conn()?;
33583363
let mut sql = String::from(
@@ -3378,6 +3383,12 @@ impl Database {
33783383
params.push(Box::new(l.to_string()));
33793384
}
33803385
}
3386+
if let Some(ws) = workspace_hash {
3387+
if !ws.is_empty() {
3388+
sql.push_str(&format!(" AND workspace_hash = ?{}", params.len() + 1));
3389+
params.push(Box::new(ws.to_string()));
3390+
}
3391+
}
33813392

33823393
sql.push_str(" ORDER BY last_accessed_unix_ms DESC");
33833394
sql.push_str(&format!(
@@ -3401,7 +3412,53 @@ impl Database {
34013412
Ok(items)
34023413
}
34033414

3415+
/// Count entities matching the same filters as `list_entities`, with no
3416+
/// LIMIT/OFFSET — lets callers (the web dashboard) report a true total
3417+
/// instead of "count of items in this page".
3418+
pub fn count_entities(
3419+
&self,
3420+
category: Option<&str>,
3421+
layer: Option<&str>,
3422+
workspace_hash: Option<&str>,
3423+
) -> Result<i64, Box<dyn std::error::Error>> {
3424+
let conn = self.conn()?;
3425+
let mut sql = String::from("SELECT COUNT(*) FROM entities WHERE archived = 0");
3426+
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
3427+
3428+
if let Some(cat) = category {
3429+
if !cat.is_empty() {
3430+
sql.push_str(&format!(" AND category = ?{}", params.len() + 1));
3431+
params.push(Box::new(cat.to_string()));
3432+
}
3433+
}
3434+
if let Some(l) = layer {
3435+
if !l.is_empty() {
3436+
sql.push_str(&format!(" AND layer = ?{}", params.len() + 1));
3437+
params.push(Box::new(l.to_string()));
3438+
}
3439+
}
3440+
if let Some(ws) = workspace_hash {
3441+
if !ws.is_empty() {
3442+
sql.push_str(&format!(" AND workspace_hash = ?{}", params.len() + 1));
3443+
params.push(Box::new(ws.to_string()));
3444+
}
3445+
}
3446+
3447+
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
3448+
params.iter().map(|p| p.as_ref()).collect();
3449+
let count: i64 = conn.query_row(&sql, param_refs.as_slice(), |r| r.get(0))?;
3450+
Ok(count)
3451+
}
3452+
34043453
/// Get recent journal events.
3454+
///
3455+
/// NOTE: the `journal` table has no `workspace_hash` column, so
3456+
/// this cannot be scoped to a workspace the way `list_entities`/
3457+
/// `get_entity_graph`/`context`/`recall_when` now are. In a federated
3458+
/// vault, journal events from every workspace are visible here. Fixing
3459+
/// this properly needs a schema migration (new column + SCHEMA_VERSION
3460+
/// bump + JournalEvent struct + every journal() call site) — tracked as
3461+
/// a follow-up rather than folded into this pass.
34053462
pub fn get_recent_journal(
34063463
&self,
34073464
limit: i64,
@@ -3434,42 +3491,70 @@ impl Database {
34343491
}
34353492

34363493
/// Build an entity link graph: nodes + edges for visualization.
3494+
/// `workspace_hash`: when `Some(non-empty)`, only entities (nodes) whose
3495+
/// workspace_hash matches are included; edges to a target outside that
3496+
/// scope are dropped rather than pointing at a node the caller never
3497+
/// receives (the dashboard's graph tab leaked cross-workspace
3498+
/// nodes/edges before this).
34373499
pub fn get_entity_graph(
34383500
&self,
3501+
workspace_hash: Option<&str>,
34393502
) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), Box<dyn std::error::Error>> {
34403503
let conn = self.conn()?;
3441-
let mut stmt = conn
3442-
.prepare("SELECT id, category, key, links FROM entities WHERE archived = 0")?;
3443-
let rows = stmt.query_map([], |row| {
3504+
let (sql, scoped) = match workspace_hash.filter(|ws| !ws.is_empty()) {
3505+
Some(_) => (
3506+
"SELECT id, category, key, links FROM entities WHERE archived = 0 AND workspace_hash = ?1",
3507+
true,
3508+
),
3509+
None => (
3510+
"SELECT id, category, key, links FROM entities WHERE archived = 0",
3511+
false,
3512+
),
3513+
};
3514+
let mut stmt = conn.prepare(sql)?;
3515+
let map_row = |row: &rusqlite::Row| {
34443516
let id: String = row.get(0)?;
34453517
let category: String = row.get(1)?;
34463518
let key: String = row.get(2)?;
34473519
let links_str: String = row.get::<_, String>(3).unwrap_or_else(|_| "[]".to_string());
34483520
let links: Vec<MemoryLink> = serde_json::from_str(&links_str).unwrap_or_default();
34493521
Ok((id, category, key, links))
3450-
})?;
3522+
};
3523+
let rows: Vec<(String, String, String, Vec<MemoryLink>)> = if scoped {
3524+
stmt.query_map(params![workspace_hash.unwrap()], map_row)?
3525+
.collect::<rusqlite::Result<Vec<_>>>()?
3526+
} else {
3527+
stmt.query_map([], map_row)?
3528+
.collect::<rusqlite::Result<Vec<_>>>()?
3529+
};
34513530

34523531
let mut nodes = Vec::new();
34533532
let mut edges = Vec::new();
34543533
let mut seen_ids = std::collections::HashSet::new();
34553534

3456-
for row in rows {
3457-
let (id, category, key, links) = row?;
3535+
for (id, category, key, links) in &rows {
34583536
if seen_ids.insert(id.clone()) {
34593537
nodes.push(GraphNode {
34603538
id: id.clone(),
3461-
label: key,
3462-
category,
3539+
label: key.clone(),
3540+
category: category.clone(),
34633541
});
34643542
}
3465-
for link in &links {
3543+
for link in links {
34663544
edges.push(GraphEdge {
34673545
from: id.clone(),
34683546
to: link.target_id.clone(),
34693547
relationship: link.relationship.clone(),
34703548
});
34713549
}
34723550
}
3551+
if scoped {
3552+
// Drop edges pointing outside the scoped node set: the target
3553+
// entity is in a different workspace, so the caller never
3554+
// receives that node and a dangling edge would be meaningless
3555+
// (or, worse, leak the existence/id of a cross-workspace entity).
3556+
edges.retain(|e| seen_ids.contains(&e.to));
3557+
}
34733558
Ok((nodes, edges))
34743559
}
34753560

src/web/dashboard.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6-
<title>Mneme Dashboard</title>
6+
<title>Perseus Vault Dashboard</title>
77
<style>
88
:root {
99
--bg: #0d1117;
@@ -132,7 +132,7 @@
132132
</head>
133133
<body>
134134
<header>
135-
<h1>Mneme</h1>
135+
<h1>Perseus Vault</h1>
136136
<span class="version">dashboard</span>
137137
<div class="search-bar">
138138
<input type="text" id="search-input" placeholder="Search memories..." autofocus>
@@ -167,7 +167,9 @@ <h1>Mneme</h1>
167167
</table>
168168
</div>
169169
</main>
170-
<script src="https://cdn.jsdelivr.net/npm/vis-network@9.1.6/dist/vis-network.min.js"></script>
170+
<script src="https://cdn.jsdelivr.net/npm/vis-network@9.1.6/dist/vis-network.min.js"
171+
integrity="sha384-MMLxWP/84QroX0uegDzRv7ib1nuKjUbUjLk2Li3jkwKWohW506l5X7B3AnChTXER"
172+
crossorigin="anonymous"></script>
171173
<script>
172174
const state = { entities: [], expandedId: null };
173175

0 commit comments

Comments
 (0)