Pluggable storage backend, network access, and emergent domain classification. Introduces MemoryStore + Embedder traits, PgMemoryStore alongside SqliteMemoryStore, HTTP MCP + API key auth, and HDBSCAN-based domain clustering. Phase 5 federation deferred to a follow-up ADR. - docs/adr/0001-pluggable-storage-and-network-access.md -- Accepted - docs/plans/0001-phase-1-storage-trait-extraction.md - docs/plans/0002-phase-2-postgres-backend.md - docs/plans/0003-phase-3-network-access.md - docs/plans/0004-phase-4-emergent-domain-classification.md - docs/prd/001-getting-centralized-vestige.md -- source RFC
57 KiB
Phase 3 Plan: Network Access and Authentication
Status: Draft Depends on: Phase 1 (MemoryStore trait), Phase 2 (PgMemoryStore, backend config) Related: docs/adr/0001-pluggable-storage-and-network-access.md (Phase 3)
Scope
In scope
- HTTP MCP Streamable endpoint at
POST /mcp(JSON-RPC body, keep existing session semantics) andGET /mcp(Server-Sent Events for long-running operations: dream, consolidate, discover, reassign). - REST API under
/api/v1/for direct HTTP clients that do not speak MCP (memories CRUD, search, consolidate trigger, stats, domains list/rename/merge/discover). api_keystable + enforcement (blake3-hashed, scopesread/write, optionaldomain_filterTEXT[],last_usedtimestamp,activeflag, revocation).- Auth middleware with three resolution paths in priority order:
Authorization: Bearer <key>thenX-API-Key: <key>then signed session cookie. All three resolve to the sameApiKeyIdentity. - Signed session cookie:
vestige_session, SameSite=Strict, HttpOnly, Secure-when-TLS, Path=/, Max-Age 8 hours. Signed with HMAC-SHA256 using a key derived fromVESTIGE_SESSION_SECRET(env) or generated + persisted to<data_dir>/session_secreton first boot. vestige keys create|list|revokeCLI subcommand (pluskeys rotateas a convenience alias ofrevoke+create).- Startup-time refusal to bind non-loopback with
auth.enabled = false(hard error, non-zero exit, stderr message, no fallback). - Dashboard login flow:
POST /dashboard/loginwith{"api_key":"vst_..."}JSON body,X-API-Keyheader, or form body; sets signed cookie; returns 200 JSON{"ok":true}for XHR or 303 to/if form. Logout atPOST /dashboard/logoutclears cookie. - Per-key
domain_filterenforced inside the auth layer: if the key hasdomain_filter = ["dev","infra"], every handler that searches or lists sees the filter pre-applied via a request extension. OptionalX-Vestige-Domain: homeheader may narrow further but may never escape the key's filter. [server]and[auth]sections investige.toml, plus backward-compatible env var bridges.VESTIGE_AUTH_TOKENcontinues to work for one minor release as a synthetic single-key fallback, but logs a deprecation warning.- Per-request request IDs and structured tracing;
last_usedwrite-back on successful auth.
Out of scope
- Phase 4 HDBSCAN domain classifier itself. The REST surface exposes domain endpoints but they may stub to empty results until Phase 4 lands.
- Real TLS termination. Assumed handled by a reverse proxy (nginx, Caddy,
Mycelium). An optional
tls_cert/tls_keypair is documented but its implementation may be deferred behind atlsCargo feature. - OAuth / OIDC / SSO. Future work.
- Rate limiting per key (documented in Open Questions, not implemented here).
- WebAuthn / passkey dashboard login. Future work.
- Fine-grained RBAC beyond
read/writescopes.
Prerequisites
Phase 1 artifacts:
vestige_core::storage::MemoryStoretrait (withSendvariant viatrait_variant::make).Embeddertrait.SqliteMemoryStoreimplementingMemoryStore.
Phase 2 artifacts:
PgMemoryStoreimplementingMemoryStore.crates/vestige-core/migrations/postgres/sqlx migrations;api_keystable schema present but enforcement path is Phase 3's job.- Runtime backend selection via
vestige.toml[storage]section returning anArc<dyn MemoryStore>.
Assumed already available in workspace:
axum = 0.8(currently pinned incrates/vestige-mcp/Cargo.toml).tower = 0.5,tower-http = 0.6(cors,set-headerfeatures already on).tokio,serde,serde_json,uuid,chrono,tracing,tracing-subscriber,thiserror,anyhow,subtle,clap,directories.
New crates required (add via cargo add -p vestige-mcp):
blake3 = "1"-- key hashing.rand = "0.9"withstd_rng(for key bytes; preferrand::rngs::OsRng).axum-extra = { version = "0.10", features = ["cookie-signed", "typed-header"] }--SignedCookieJar,Cookie,Key.hmac = "0.12"+sha2 = "0.10"-- HMAC-SHA256 for the session secret derivation (not required ifaxum-extra'sSignedCookieJaris used, but retained for the pure-token-signing path). RECOMMENDATION: rely solely onaxum-extra::extract::cookie::{Key, SignedCookieJar}.tower-httpfeatures bump: addtraceandrequest-id.async-stream = "0.3"-- emitting SSE events from async closures.futures-utilalready present -- forStreamadapters.base64 = "0.22"-- emitting / parsing the random bytes in thevst_...prefix. Use theURL_SAFE_NO_PADalphabet.zeroize = "1"(optional, recommended) -- scrub the plaintext key in RAM after hashing.
cargo add commands (do not execute here, leave to implementation):
cargo add -p vestige-mcp blake3 rand base64 zeroize async-stream
cargo add -p vestige-mcp axum-extra --features cookie-signed,typed-header
cargo add -p vestige-mcp tower-http --features trace,request-id,cors,set-header
JSON-RPC library: the project uses a hand-rolled JsonRpcRequest /
JsonRpcResponse pair in crates/vestige-mcp/src/protocol/types.rs. Keep it
in Phase 3 (no jsonrpsee migration). Streamable HTTP remains implemented as
POST /mcp + session header + GET /mcp SSE. See Open Questions for rationale.
Deliverables
-
crates/vestige-mcp/src/auth/module (new). Houses key generation, key verification, identity resolution, scopes, domain-filter extractor, session key type, and error types. -
crates/vestige-mcp/src/auth/keys.rs-- key format, generation, blake3 hashing, store-facing trait methods for list / create / revoke / verify. -
crates/vestige-mcp/src/auth/middleware.rs-- axumfrom_fnmiddleware that populatesExtension<Identity>on the request, rejects unauthenticated requests with 401, insufficient scope with 403. -
crates/vestige-mcp/src/auth/session.rs--SignedCookieJarintegration,session_key()loader (env or persisted file),issue_session()andrevoke_session()helpers. -
crates/vestige-mcp/src/http/module split out ofprotocol/http.rs:http/mcp.rs-- MCP JSON-RPC endpoint (adapted from the currentpost_mcp/delete_mcp, with auth middleware now gating).http/mcp_sse.rs-- SSE handler forGET /mcplong-running ops.http/rest.rs--/api/v1/*handlers.http/mod.rs--build_router(),start_server(), bind-safety check, layer stack assembly.
-
crates/vestige-mcp/src/http/errors.rs-- uniformApiErrorenum andIntoResponseimplementation. Maps to RFC 7807 problem+json for REST and plain JSON for/mcp. -
Dashboard patch:
crates/vestige-mcp/src/dashboard/mod.rs-- add the auth middleware to the dashboard router, add/dashboard/login+/dashboard/logoutendpoints, keep/api/healthunauthenticated. -
crates/vestige-mcp/src/bin/cli.rs-- newKeyssubcommand group (create,list,revoke,rotate). -
crates/vestige-mcp/src/config.rs(new file) -- typedServerConfig,AuthConfig,StorageConfigloader fromvestige.toml, merging env var overrides, validating the non-loopback + auth-disabled combination. -
SQL migration
crates/vestige-core/migrations/postgres/0300_api_keys_enforcement.sqland SQLite equivalentcrates/vestige-core/migrations/sqlite/0300_api_keys.sql:api_keystable (if not already created in Phase 2), withkey_hashUNIQUE,labelNOT NULL,scopesTEXT[] default{read,write},domain_filterTEXT[] default{},created_at,last_used,active BOOLEAN DEFAULT true.- Index on
key_hash(unique already), and onactive WHERE active.
-
MemoryStoretrait extension (Phase 2 may already cover this; if not, finalize in Phase 3):list_api_keys,create_api_key,revoke_api_key,find_api_key_by_hash,touch_api_key_last_used. -
Docs updates:
docs/env-vars.md(new) -- one sheet for all runtime env vars.README.mdserver-mode section.docs/adr/0001-*.md-- mark Phase 3 as Implemented when merged.
Detailed Task Breakdown
D1. Auth module skeleton
Files:
crates/vestige-mcp/src/auth/mod.rscrates/vestige-mcp/src/auth/keys.rscrates/vestige-mcp/src/auth/session.rscrates/vestige-mcp/src/auth/middleware.rscrates/vestige-mcp/src/auth/errors.rs
auth/mod.rs:
pub mod errors;
pub mod keys;
pub mod middleware;
pub mod session;
pub use errors::AuthError;
pub use keys::{ApiKey, ApiKeyPlaintext, ApiKeyRecord, Scope};
pub use middleware::{Identity, auth_layer};
pub use session::{SessionConfig, session_key};
auth/errors.rs:
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("missing credentials")]
MissingCredentials,
#[error("invalid credentials")]
InvalidCredentials,
#[error("key revoked")]
Revoked,
#[error("insufficient scope: required {required}")]
InsufficientScope { required: &'static str },
#[error("domain not permitted for this key: {domain}")]
DomainNotAllowed { domain: String },
#[error("internal auth error")]
Internal,
}
#[derive(Serialize)]
struct Problem<'a> {
#[serde(rename = "type")]
kind: &'a str,
title: &'a str,
status: u16,
detail: &'a str,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, title) = match self {
AuthError::MissingCredentials => (StatusCode::UNAUTHORIZED, "unauthorized"),
AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "unauthorized"),
AuthError::Revoked => (StatusCode::UNAUTHORIZED, "unauthorized"),
AuthError::InsufficientScope { .. } => (StatusCode::FORBIDDEN, "forbidden"),
AuthError::DomainNotAllowed { .. } => (StatusCode::FORBIDDEN, "forbidden"),
AuthError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
};
let detail = self.to_string();
let body = axum::Json(Problem {
kind: "about:blank",
title,
status: status.as_u16(),
detail: &detail,
});
let mut r = (status, body).into_response();
r.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/problem+json"),
);
r
}
}
D2. Key format and generation
File: crates/vestige-mcp/src/auth/keys.rs
- Key on wire:
vst_<22-byte base64url-no-pad>. 22 bytes = 176 bits entropy. Encoded length ~30 chars. Full string ~34 chars including thevst_prefix. - Hash stored in DB:
blake3(key_plaintext)hex lowercase (32 bytes -> 64 hex chars). - Hash prefix on list: first 12 hex characters, e.g.
key_hash[..12]for human display.
Signatures:
use blake3::Hasher;
use rand::rngs::OsRng;
use rand::TryRngCore;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use zeroize::Zeroize;
const KEY_PREFIX: &str = "vst_";
const KEY_RANDOM_BYTES: usize = 22;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Scope {
Read,
Write,
}
impl Scope {
pub fn as_str(&self) -> &'static str {
match self {
Scope::Read => "read",
Scope::Write => "write",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"read" => Some(Scope::Read),
"write" => Some(Scope::Write),
_ => None,
}
}
}
/// The plaintext key. Shown to the user exactly once.
/// Zeroed on drop.
pub struct ApiKeyPlaintext(String);
impl ApiKeyPlaintext {
pub fn as_str(&self) -> &str { &self.0 }
pub fn into_inner(mut self) -> String {
std::mem::take(&mut self.0)
}
}
impl Drop for ApiKeyPlaintext {
fn drop(&mut self) { self.0.zeroize(); }
}
#[derive(Clone, Debug)]
pub struct ApiKeyRecord {
pub id: uuid::Uuid,
pub key_hash: String, // hex-encoded blake3(plaintext)
pub label: String,
pub scopes: Vec<Scope>,
pub domain_filter: Vec<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used: Option<chrono::DateTime<chrono::Utc>>,
pub active: bool,
}
pub fn generate_key() -> ApiKeyPlaintext {
let mut bytes = [0u8; KEY_RANDOM_BYTES];
OsRng.try_fill_bytes(&mut bytes).expect("OsRng");
let encoded = URL_SAFE_NO_PAD.encode(&bytes);
bytes.zeroize();
ApiKeyPlaintext(format!("{}{}", KEY_PREFIX, encoded))
}
pub fn hash_key(plaintext: &str) -> String {
let mut hasher = Hasher::new();
hasher.update(plaintext.as_bytes());
hasher.finalize().to_hex().to_string()
}
pub fn verify_key(plaintext: &str, stored_hash_hex: &str) -> bool {
use subtle::ConstantTimeEq;
let computed = hash_key(plaintext);
computed.as_bytes().ct_eq(stored_hash_hex.as_bytes()).unwrap_u8() == 1
}
Helpers on a thin repository trait that both backends implement through
MemoryStore (Phase 2 already adds the required columns; Phase 3 wires the
methods):
#[async_trait::async_trait]
pub trait ApiKeyStore: Send + Sync + 'static {
async fn create_api_key(&self, rec: &ApiKeyRecord) -> anyhow::Result<()>;
async fn find_api_key_by_hash(&self, hash: &str) -> anyhow::Result<Option<ApiKeyRecord>>;
async fn list_api_keys(&self) -> anyhow::Result<Vec<ApiKeyRecord>>;
async fn revoke_api_key(&self, id: uuid::Uuid) -> anyhow::Result<bool>;
async fn touch_api_key_last_used(&self, id: uuid::Uuid) -> anyhow::Result<()>;
}
(If Phase 2 already bolted these onto MemoryStore, ApiKeyStore is simply a
re-export of the relevant subset.)
D3. Session cookie
File: crates/vestige-mcp/src/auth/session.rs
- Cookie name:
vestige_session. - Cookie attributes:
HttpOnly,SameSite=Strict,Path=/,Max-Age=28800(8h),Securewhen the server is running behind TLS (detected fromconfig.server.tls_cert.is_some()or theX-Forwarded-Prototrusted header; default: setSecurewheneverconfig.server.bindis non-loopback). - Payload: serialized
SessionClaims { key_id: Uuid, issued_at: i64, expires_at: i64 }encoded asserde_jsonthen base64url. The signing is handled byaxum-extra::extract::cookie::SignedCookieJar(HMAC via a 64-byteKey). Any tampering or truncation is rejected by the jar automatically. - Key material: 64 random bytes, stored at
<data_dir>/session_secret(mode 0600) or overridden byVESTIGE_SESSION_SECRET(base64url-encoded 64 bytes, reject if shorter).
Signatures:
use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
const COOKIE_NAME: &str = "vestige_session";
const DEFAULT_TTL: Duration = Duration::hours(8);
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionClaims {
pub key_id: uuid::Uuid,
pub iat: i64,
pub exp: i64,
}
pub fn session_key(data_dir: &std::path::Path) -> anyhow::Result<Key> {
// 1) env override
if let Ok(env_val) = std::env::var("VESTIGE_SESSION_SECRET") {
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(env_val.trim())?;
anyhow::ensure!(raw.len() >= 64, "VESTIGE_SESSION_SECRET must be >= 64 bytes");
return Ok(Key::from(&raw));
}
// 2) persisted file
let path = data_dir.join("session_secret");
if path.exists() {
let bytes = std::fs::read(&path)?;
return Ok(Key::from(&bytes));
}
// 3) generate
use rand::TryRngCore;
let mut bytes = [0u8; 64];
rand::rngs::OsRng.try_fill_bytes(&mut bytes)?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
std::fs::create_dir_all(data_dir).ok();
let mut f = std::fs::OpenOptions::new()
.create_new(true).write(true).mode(0o600).open(&path)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
#[cfg(not(unix))]
std::fs::write(&path, &bytes)?;
Ok(Key::from(&bytes))
}
pub fn issue_session(
jar: SignedCookieJar,
key_id: uuid::Uuid,
secure: bool,
) -> SignedCookieJar {
let now = Utc::now();
let claims = SessionClaims {
key_id,
iat: now.timestamp(),
exp: (now + DEFAULT_TTL).timestamp(),
};
let value = serde_json::to_string(&claims).expect("serialize claims");
let mut cookie = Cookie::new(COOKIE_NAME, value);
cookie.set_http_only(true);
cookie.set_same_site(SameSite::Strict);
cookie.set_path("/");
cookie.set_max_age(cookie::time::Duration::seconds(DEFAULT_TTL.num_seconds()));
cookie.set_secure(secure);
jar.add(cookie)
}
pub fn revoke_session(jar: SignedCookieJar) -> SignedCookieJar {
jar.remove(Cookie::from(COOKIE_NAME))
}
pub fn claims_from(jar: &SignedCookieJar) -> Option<SessionClaims> {
let c = jar.get(COOKIE_NAME)?;
let claims: SessionClaims = serde_json::from_str(c.value()).ok()?;
if claims.exp < Utc::now().timestamp() { return None; }
Some(claims)
}
D4. Auth middleware
File: crates/vestige-mcp/src/auth/middleware.rs
Identity carried through the request:
#[derive(Clone, Debug)]
pub struct Identity {
pub key_id: uuid::Uuid,
pub label: String,
pub scopes: Vec<Scope>,
pub domain_filter: Vec<String>,
pub via: AuthVia,
}
#[derive(Clone, Copy, Debug)]
pub enum AuthVia {
Bearer,
ApiKeyHeader,
SessionCookie,
}
Middleware (axum 0.8):
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_extra::extract::cookie::SignedCookieJar;
use std::sync::Arc;
pub async fn auth_layer(
State(state): State<Arc<AppCtx>>,
jar: SignedCookieJar,
mut request: Request,
next: Next,
) -> Response {
// Allowlist endpoints that never require auth:
let path = request.uri().path();
if path == "/api/health" || path == "/api/v1/health" ||
path == "/dashboard/login" {
return next.run(request).await;
}
let via_and_key = extract_credentials(request.headers(), &jar);
let outcome = match via_and_key {
Some((AuthVia::Bearer, key)) | Some((AuthVia::ApiKeyHeader, key)) => {
resolve_by_plaintext(&state, &key).await.map(|id| (id, via_and_key.unwrap().0))
}
Some((AuthVia::SessionCookie, key_id_str)) => {
let id = uuid::Uuid::parse_str(&key_id_str).map_err(|_| AuthError::InvalidCredentials)?;
resolve_by_key_id(&state, id).await.map(|id| (id, AuthVia::SessionCookie))
}
None => Err(AuthError::MissingCredentials),
};
let identity = match outcome {
Ok((id, via)) => Identity { via, ..id },
Err(e) => return e.into_response(),
};
// touch last_used asynchronously; do not block request path
let st2 = state.clone();
let kid = identity.key_id;
tokio::spawn(async move { let _ = st2.store.touch_api_key_last_used(kid).await; });
request.extensions_mut().insert(identity);
next.run(request).await
}
Credential extraction (priority: Bearer > X-API-Key > cookie):
fn extract_credentials(
headers: &axum::http::HeaderMap,
jar: &SignedCookieJar,
) -> Option<(AuthVia, String)> {
if let Some(v) = headers.get(header::AUTHORIZATION).and_then(|h| h.to_str().ok()) {
if let Some(rest) = v.strip_prefix("Bearer ") {
return Some((AuthVia::Bearer, rest.trim().to_string()));
}
}
if let Some(v) = headers.get("x-api-key").and_then(|h| h.to_str().ok()) {
return Some((AuthVia::ApiKeyHeader, v.trim().to_string()));
}
if let Some(claims) = crate::auth::session::claims_from(jar) {
return Some((AuthVia::SessionCookie, claims.key_id.to_string()));
}
None
}
Resolution helpers:
async fn resolve_by_plaintext(st: &AppCtx, key: &str) -> Result<Identity, AuthError> {
let hash = crate::auth::keys::hash_key(key);
let rec = st.store.find_api_key_by_hash(&hash).await
.map_err(|_| AuthError::Internal)?
.ok_or(AuthError::InvalidCredentials)?;
if !rec.active { return Err(AuthError::Revoked); }
Ok(Identity {
key_id: rec.id, label: rec.label, scopes: rec.scopes,
domain_filter: rec.domain_filter, via: AuthVia::Bearer,
})
}
async fn resolve_by_key_id(st: &AppCtx, id: uuid::Uuid) -> Result<Identity, AuthError> {
let rec = st.store.find_api_key_by_id(id).await
.map_err(|_| AuthError::Internal)?
.ok_or(AuthError::InvalidCredentials)?;
if !rec.active { return Err(AuthError::Revoked); }
Ok(Identity {
key_id: rec.id, label: rec.label, scopes: rec.scopes,
domain_filter: rec.domain_filter, via: AuthVia::SessionCookie,
})
}
Scope guard extractor (per-handler opt-in):
pub struct RequireScope<const WRITE: bool>;
impl<S, const WRITE: bool> axum::extract::FromRequestParts<S> for RequireScope<WRITE>
where S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts, _state: &S,
) -> Result<Self, Self::Rejection> {
let id = parts.extensions.get::<Identity>().ok_or(AuthError::MissingCredentials)?;
let need = if WRITE { Scope::Write } else { Scope::Read };
if !id.scopes.contains(&need) {
return Err(AuthError::InsufficientScope {
required: if WRITE { "write" } else { "read" },
});
}
Ok(RequireScope)
}
}
Domain scoping:
/// Returns the effective domain filter for the request:
/// - Intersect the key's domain_filter with any X-Vestige-Domain header.
/// - Empty key filter means "all domains", so the header is authoritative.
/// - A header that names a domain outside the key filter returns
/// `Err(DomainNotAllowed)`.
pub fn effective_domain_filter(
id: &Identity, header: Option<&str>,
) -> Result<Option<Vec<String>>, AuthError> {
let header_dom = header.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
match (id.domain_filter.as_slice(), header_dom) {
([], None) => Ok(None),
([], Some(h)) => Ok(Some(vec![h])),
(filter, None) => Ok(Some(filter.to_vec())),
(filter, Some(h)) => {
if filter.iter().any(|d| d == &h) {
Ok(Some(vec![h]))
} else {
Err(AuthError::DomainNotAllowed { domain: h })
}
}
}
}
D5. Layer ordering
Router assembly in http/mod.rs::build_router:
let trace = tower_http::trace::TraceLayer::new_for_http();
let request_id = tower_http::request_id::SetRequestIdLayer::x_request_id(
tower_http::request_id::MakeRequestUuid);
let propagate_id = tower_http::request_id::PropagateRequestIdLayer::x_request_id();
let cors = CorsLayer::new()
.allow_origin(cfg.server.allowed_origins())
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION,
HeaderName::from_static("x-api-key"),
HeaderName::from_static("x-vestige-domain"),
HeaderName::from_static("mcp-session-id")])
.allow_credentials(true);
let app = Router::new()
// Unauth routes first (not subjected to auth_layer by path allowlist)
.route("/api/health", get(health))
.route("/dashboard/login", post(login))
.route("/dashboard/logout", post(logout))
// MCP + REST + dashboard
.route("/mcp", post(http::mcp::post_mcp).get(http::mcp_sse::get_mcp_sse)
.delete(http::mcp::delete_mcp))
.nest("/api/v1", http::rest::router())
.merge(dashboard::router())
// Auth middleware applied via from_fn_with_state (allowlist inside)
.layer(axum::middleware::from_fn_with_state(ctx.clone(), auth_layer))
// Outermost: tracing, request-id, cors, body limit, concurrency
.layer(
ServiceBuilder::new()
.layer(trace)
.layer(request_id)
.layer(propagate_id)
.layer(cors)
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
.layer(ConcurrencyLimitLayer::new(CONCURRENCY_LIMIT))
)
.with_state(ctx);
Axum applies layer() calls outermost-first in the order they are declared.
The result here: request -> trace -> request-id -> CORS -> body-limit ->
concurrency -> auth -> handler. Auth must wrap the handlers but be inside
tracing so its spans can log auth outcomes.
D6. MCP endpoints
File: crates/vestige-mcp/src/http/mcp.rs
POST /mcp -- keep the session-based structure already in protocol/http.rs
but use the Identity injected by the auth layer instead of a shared
auth_token:
pub async fn post_mcp(
State(ctx): State<Arc<AppCtx>>,
Extension(id): Extension<Identity>,
headers: HeaderMap,
Json(request): Json<JsonRpcRequest>,
) -> Response { ... }
Auth happens in the layer, so this handler cannot be reached without a valid
Identity. Scope check: all MCP writes (tools that mutate) require
RequireScope<true>. Use an enum of MCP methods or a method -> required-scope
map. tools/list, resources/list, initialize, ping are read-only.
tools/call is conservatively classified as write; the per-tool dispatch
inside McpServer::handle_tools_call may further reject writes when the tool
name is read-only and the key lacks write.
DELETE /mcp -- unchanged semantics, drops the session.
GET /mcp -- SSE. Implementation in http/mcp_sse.rs:
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::extract::Query;
use futures_util::stream::Stream;
use async_stream::stream;
use std::time::Duration;
#[derive(serde::Deserialize)]
pub struct SseParams {
pub op: String, // "dream" | "consolidate" | "discover" | "reassign"
pub session: Option<String>, // optional operation correlation id
}
pub async fn get_mcp_sse(
State(ctx): State<Arc<AppCtx>>,
Extension(_id): Extension<Identity>,
Query(params): Query<SseParams>,
) -> Result<Sse<impl Stream<Item = Result<Event, axum::Error>>>, AuthError> {
let op = params.op.clone();
let ctx2 = ctx.clone();
let s = stream! {
yield Ok(Event::default().event("start").data(format!("{{\"op\":\"{}\"}}", op)));
match op.as_str() {
"dream" => {
let mut rx = ctx2.cognitive.lock().await.begin_dream_stream().await;
while let Some(ev) = rx.recv().await {
yield Ok(Event::default().event("progress").json_data(ev)?);
}
yield Ok(Event::default().event("done").data("{}"));
}
"consolidate" => { /* same pattern over Storage::run_consolidation_stream */ }
"discover" => { /* Phase 4 */ }
"reassign" => { /* Phase 4 */ }
other => {
yield Ok(Event::default().event("error")
.data(format!("{{\"message\":\"unknown op {}\"}}", other)));
}
}
};
Ok(Sse::new(s).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
}
SSE event shape (stable contract, document in docs/http-api.md):
event: start
data: {"op":"dream"}
event: progress
data: {"stage":"replay","processed":12,"total":50}
event: progress
data: {"stage":"cross_reference","processed":25,"total":50}
event: done
data: {"nodes_processed":50,"duration_ms":14320}
The keep-alive hint is 15s to survive most proxy timeouts.
D7. REST API
File: crates/vestige-mcp/src/http/rest.rs
Routes:
pub fn router() -> Router<Arc<AppCtx>> {
Router::new()
.route("/health", get(health))
.route("/memories", post(create_memory).get(list_memories))
.route("/memories/{id}", get(get_memory).put(update_memory).delete(delete_memory))
.route("/memories/{id}/promote", post(promote_memory))
.route("/memories/{id}/demote", post(demote_memory))
.route("/search", post(search_memories))
.route("/consolidate", post(trigger_consolidation))
.route("/stats", get(get_stats))
.route("/domains", get(list_domains))
.route("/domains/discover", post(trigger_discovery))
.route("/domains/{id}", put(rename_domain).delete(delete_domain))
.route("/domains/{id}/merge", post(merge_domain))
.route("/keys", post(create_key).get(list_keys))
.route("/keys/{id}", delete(revoke_key))
}
Representative signatures:
#[derive(serde::Deserialize)]
pub struct CreateMemoryReq {
pub content: String,
pub node_type: Option<String>,
pub tags: Option<Vec<String>>,
pub source: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(serde::Serialize)]
pub struct MemoryView { /* flat projection of MemoryRecord */ }
pub async fn create_memory(
State(ctx): State<Arc<AppCtx>>,
Extension(id): Extension<Identity>,
_: RequireScope<true>,
Json(req): Json<CreateMemoryReq>,
) -> Result<(StatusCode, Json<MemoryView>), ApiError> {
let effective = effective_domain_filter(&id, None)?;
let rec = ctx.store.insert_from_rest(req, effective).await?;
Ok((StatusCode::CREATED, Json(MemoryView::from(rec))))
}
pub async fn search_memories(
State(ctx): State<Arc<AppCtx>>,
Extension(id): Extension<Identity>,
_: RequireScope<false>,
headers: HeaderMap,
Json(req): Json<SearchReq>,
) -> Result<Json<SearchResp>, ApiError> {
let dom_header = headers.get("x-vestige-domain").and_then(|h| h.to_str().ok());
let effective = effective_domain_filter(&id, dom_header)?;
let q = SearchQuery { domains: effective, ..req.into() };
let res = ctx.store.search(&q).await?;
Ok(Json(SearchResp::from(res)))
}
trigger_consolidation returns 202 Accepted + a JSON body with a session_id
the client may pass to GET /mcp?op=consolidate&session=... to stream
progress.
D8. Error mapping
File: crates/vestige-mcp/src/http/errors.rs
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error(transparent)] Auth(#[from] AuthError),
#[error("bad request: {0}")] BadRequest(String),
#[error("not found")] NotFound,
#[error("conflict: {0}")] Conflict(String),
#[error(transparent)] Store(#[from] anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::Auth(a) => a.into_response(),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, problem(400, "bad_request", &m)).into_response(),
ApiError::NotFound => (StatusCode::NOT_FOUND, problem(404, "not_found", "")).into_response(),
ApiError::Conflict(m) => (StatusCode::CONFLICT, problem(409, "conflict", &m)).into_response(),
ApiError::Store(e) => {
tracing::error!(err = %e, "store error");
(StatusCode::INTERNAL_SERVER_ERROR, problem(500, "internal", "internal error")).into_response()
}
}
}
}
All MCP JSON-RPC error mapping is unchanged (done in McpServer); only
transport-level errors (401/403) leave that path.
D9. Config loader and bind-safety check
File: crates/vestige-mcp/src/config.rs
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_bind")]
pub bind: String, // "127.0.0.1:3928"
#[serde(default = "default_dashboard_port")]
pub dashboard_port: u16,
#[serde(default)] pub tls_cert: Option<std::path::PathBuf>,
#[serde(default)] pub tls_key: Option<std::path::PathBuf>,
#[serde(default)] pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AuthConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)] pub session_secret_file: Option<std::path::PathBuf>,
}
impl ServerConfig {
pub fn parsed_bind(&self) -> anyhow::Result<std::net::SocketAddr> {
self.bind.parse().map_err(|e: std::net::AddrParseError|
anyhow::anyhow!("invalid bind {}: {}", self.bind, e))
}
}
Bind-safety check (called during start_server):
pub fn enforce_bind_safety(server: &ServerConfig, auth: &AuthConfig) -> anyhow::Result<()> {
let addr = server.parsed_bind()?;
let is_loopback = match addr.ip() {
std::net::IpAddr::V4(v) => v.is_loopback(),
std::net::IpAddr::V6(v) => v.is_loopback(),
};
if !is_loopback && !auth.enabled {
anyhow::bail!(
"refusing to bind {} with auth disabled; \
set [auth] enabled = true in vestige.toml or \
change [server] bind to a loopback address",
addr
);
}
Ok(())
}
main.rs and the serve CLI both call enforce_bind_safety before
TcpListener::bind. On failure: eprintln! the error, std::process::exit(2).
Env bridge:
VESTIGE_HTTP_BIND(existing) ->server.bindhost part.VESTIGE_HTTP_PORT(existing) ->server.bindport part.VESTIGE_DASHBOARD_PORT(existing) ->server.dashboard_port.VESTIGE_AUTH_TOKEN(deprecated) -- when set, synthesize a virtualApiKeyRecordwithid = all-zero UUID,scopes = [read, write],domain_filter = [],active = true, hash stored in memory only. Log a warning on every startup:VESTIGE_AUTH_TOKEN is deprecated; use 'vestige keys create' and set auth.enabled=true instead. Will be removed in v2.2.0.VESTIGE_SESSION_SECRET-- see D3.
D10. Dashboard login + logout
File: crates/vestige-mcp/src/dashboard/handlers.rs (additions).
#[derive(serde::Deserialize)]
pub struct LoginBody {
pub api_key: String,
}
pub async fn login(
State(state): State<AppState>,
jar: SignedCookieJar,
headers: HeaderMap,
body: Option<Json<LoginBody>>,
) -> Result<(SignedCookieJar, Json<serde_json::Value>), AuthError> {
// Accept key in either JSON body or X-API-Key header
let plaintext = body.map(|b| b.0.api_key)
.or_else(|| headers.get("x-api-key").and_then(|h| h.to_str().ok()).map(String::from))
.ok_or(AuthError::MissingCredentials)?;
let hash = crate::auth::keys::hash_key(&plaintext);
let rec = state.store.find_api_key_by_hash(&hash).await
.map_err(|_| AuthError::Internal)?
.ok_or(AuthError::InvalidCredentials)?;
if !rec.active { return Err(AuthError::Revoked); }
let secure = state.config.server.tls_cert.is_some();
let jar = crate::auth::session::issue_session(jar, rec.id, secure);
Ok((jar, Json(serde_json::json!({
"ok": true, "key_id": rec.id, "label": rec.label,
"scopes": rec.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
"domains": rec.domain_filter,
}))))
}
pub async fn logout(jar: SignedCookieJar)
-> (SignedCookieJar, Json<serde_json::Value>)
{
(crate::auth::session::revoke_session(jar),
Json(serde_json::json!({"ok": true})))
}
Dashboard router integration: login/logout are appended before auth_layer
is applied, so they are reachable unauthenticated. The dashboard SPA asset
routes (/dashboard, /dashboard/{*path}) remain publicly readable so the
login page can load; the /api/* dashboard endpoints are gated by
auth_layer. (The existing health endpoint keeps its current behaviour.)
D11. vestige keys CLI
File: crates/vestige-mcp/src/bin/cli.rs additions.
#[derive(Subcommand)]
enum Commands {
// ... existing
/// Manage API keys
Keys {
#[command(subcommand)]
sub: KeyCmd,
},
}
#[derive(Subcommand)]
enum KeyCmd {
/// Create a new API key
Create {
#[arg(long)] label: String,
#[arg(long, value_delimiter = ',', default_values_t = ["read".to_string(), "write".to_string()])]
scopes: Vec<String>,
/// Restrict the key to listed domains (comma-separated). Empty = all domains.
#[arg(long, value_delimiter = ',')]
domains: Vec<String>,
},
/// List existing keys (never shows plaintext)
List {
/// Include revoked keys in the output
#[arg(long)] all: bool,
},
/// Revoke a key by id or by hash prefix
Revoke {
/// Id (UUID) or hash prefix (first 12 hex chars)
id_or_prefix: String,
},
/// Revoke and re-create with the same scopes/label
Rotate {
id_or_prefix: String,
},
}
Create outputs the plaintext exactly once on stdout (for piping into env
files) and a confirmation on stderr. Use colored output only on stderr to keep
stdout machine-readable.
fn run_keys_create(...) -> anyhow::Result<()> {
let store = open_store()?; // Arc<dyn MemoryStore + ApiKeyStore>
let plaintext = crate::auth::keys::generate_key();
let hash = crate::auth::keys::hash_key(plaintext.as_str());
let rec = ApiKeyRecord {
id: uuid::Uuid::new_v4(),
key_hash: hash, label, scopes, domain_filter: domains,
created_at: chrono::Utc::now(),
last_used: None, active: true,
};
block_on(store.create_api_key(&rec))?;
// stderr: human-readable
eprintln!("{} {}", "Created key:".green().bold(), rec.label);
eprintln!(" id: {}", rec.id);
eprintln!(" scopes: {}", rec.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(","));
eprintln!(" domains: {}", if rec.domain_filter.is_empty() { "all".to_string() } else { rec.domain_filter.join(",") });
eprintln!();
eprintln!("{}", "Store the plaintext key now. It will not be shown again.".yellow());
// stdout: ONLY the plaintext, for scripting
println!("{}", plaintext.as_str());
Ok(())
}
List:
kid label scopes domains last_used hash
d3a8... macbook read,write all 2026-04-20 11:02 a1b2c3d4e5f6
...
Never print the plaintext. Show only hash[..12].
D12. Migrations
Postgres 0300_api_keys.sql (idempotent; Phase 2 may have already created the
table, in which case this migration is a no-op CREATE TABLE IF NOT EXISTS):
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT ARRAY['read','write'],
domain_filter TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS idx_api_keys_active
ON api_keys (active) WHERE active;
SQLite 0300_api_keys.sql:
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT 'read,write', -- comma-joined
domain_filter TEXT NOT NULL DEFAULT '', -- comma-joined, '' = all
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_api_keys_active
ON api_keys (active) WHERE active = 1;
Both backends' trait impls convert to/from ApiKeyRecord.
D13. Wiring main.rs and the serve CLI path
main.rs refactor:
Config::load()readsvestige.toml(if present) and overlays env vars.- Run
enforce_bind_safety(&cfg.server, &cfg.auth)before spawning any listener. On failure, print to stderr and exit 2. - Build
AppCtxwithArc<dyn MemoryStore + ApiKeyStore>,CognitiveEngine, event bus,session_key,config. build_router(ctx)returns a single AxumRouterthat covers MCP, REST, and dashboard.axum::serve(listener, app).await.- The stdio MCP transport continues to run in parallel (unchanged) for desktop / Claude Code single-user scenarios.
serve CLI subcommand: identical flow minus stdio.
D14. Docs
docs/env-vars.mdnew: table of every supported env var, default, purpose, deprecation status.- Section in
README.md: "Running Vestige as a network server". - Cheat-sheet section in
CLAUDE.mdfor: create a key, start the server, curl smoke test.
Test Plan
Unit tests (colocated under #[cfg(test)])
-
auth/keys.rs:generate_key_has_prefix_and_length()-- assertsvst_prefix and 34-ish char total, regex^vst_[A-Za-z0-9_-]{29}$.hash_key_blake3_is_stable_and_hex()-- fixed vector test.verify_key_accepts_same_input()/verify_key_rejects_tampered()/verify_key_rejects_length_mismatch().keys_are_unique_in_a_loop()-- 10_000 iterations, no collisions.plaintext_zeroed_on_drop()-- unsafe peek into the backing buffer through a wrapper that exposes bytes for the test only.
-
auth/session.rs:round_trip_claims_through_signed_jar().expired_cookie_is_rejected()-- mint a cookie withexp = iat - 60and confirmclaims_fromreturnsNone.tampered_cookie_is_rejected()-- flip one byte in the signed segment, confirm the jar drops it.session_key_env_overrides_file().session_key_generated_file_has_mode_0600_on_unix().
-
auth/middleware.rs:extract_credentials_prefers_bearer_over_api_key_header().extract_credentials_falls_back_to_cookie().effective_domain_filter_empty_means_all().effective_domain_filter_header_narrows_within_key_filter().effective_domain_filter_rejects_header_outside_key_filter().missing_credentials_returns_401().revoked_key_returns_401().insufficient_scope_returns_403().
-
config.rs:parse_vestige_toml_with_server_and_auth_sections().env_vars_override_toml_bind().enforce_bind_safety_rejects_0_0_0_0_with_auth_disabled().enforce_bind_safety_allows_0_0_0_0_with_auth_enabled().enforce_bind_safety_allows_loopback_with_auth_disabled().
-
http/errors.rs:not_found_emits_problem_json_with_correct_content_type().bad_request_includes_detail_field().
-
http/mcp.rs:post_mcp_unauth_returns_401()(this would normally be caught by the middleware; kept as a unit test by constructing the Router minus the middleware to exercise the handler's own error paths).
Integration tests (tests/phase_3/)
All tests spin up the full Axum stack in-process on a random port via
tokio::net::TcpListener::bind("127.0.0.1:0"), wire a SqliteMemoryStore in
a TempDir, and issue HTTP calls with reqwest.
Files (each one a standalone binary test file):
-
phase_3/common/mod.rs-- shared harness (spawn_server(),create_test_key(),client()). -
phase_3/http_mcp_round_trip.rs-- boot server, mint a key, sendinitializeoverPOST /mcpwithAuthorization: Bearer vst_..., follow withtools/list, assert we see the expected tool count (greater than 20). -
phase_3/http_sse_stream.rs--POST /api/v1/consolidatereturns 202 +session_id.GET /mcp?op=consolidate&session=...streams at least oneprogressand onedoneevent. Useeventsource-clientdev dep, or parse the stream manually. -
phase_3/rest_api_crud.rs-- exercises each REST endpoint in turn:POST /api/v1/memories-> 201 + body.GET /api/v1/memories/{id}-> 200.PUT /api/v1/memories/{id}-> 200.POST /api/v1/search-> 200 with the new memory in results.POST /api/v1/memories/{id}/promote-> 200.GET /api/v1/stats-> 200.GET /api/v1/domains-> 200 (likely empty).DELETE /api/v1/memories/{id}-> 204.
-
phase_3/auth_bearer_token.rs:- unauth:
GET /api/v1/statsreturns 401 andContent-Type: application/problem+json. - valid Bearer: same call returns 200.
- revoked key:
POST /api/v1/keys/{id}DELETE then reuse -> 401. - tampered Bearer (last char flipped) -> 401.
- unauth:
-
phase_3/auth_api_key_header.rs:X-API-Key: vst_...alone -> 200.- Both Bearer and X-API-Key with different values -> Bearer wins (asserted via a key that is read-only in Bearer + full-scope X-API-Key, then confirming a write 403s).
-
phase_3/auth_session_cookie.rs:POST /dashboard/loginwith valid key -> 200 +Set-Cookie: vestige_session=...; HttpOnly; SameSite=Strict; Path=/.- reuse cookie:
GET /api/v1/statsreturns 200. - tampered cookie (change one char): -> 401.
POST /dashboard/logout->Set-Cookie: vestige_session=; Max-Age=0.
-
phase_3/auth_domain_filter.rs:- Key with
domain_filter = ["dev"]:POST /api/v1/searchwithout header -> search is scoped to["dev"](insert fixtures with two domains, assert onlydevrows returned).X-Vestige-Domain: dev-> same.X-Vestige-Domain: home-> 403 with detaildomain not permitted.
- Key with empty filter +
X-Vestige-Domain: dev-> scoped to["dev"]. - Key with empty filter + no header -> no scoping.
- Key with
-
phase_3/auth_scope_enforcement.rs:- read-only key cannot call
POST /api/v1/memories-> 403. - read-only key CAN call
POST /api/v1/search-> 200.
- read-only key cannot call
-
phase_3/bind_safety_nonlocalhost_without_auth.rs:- Spawn
vestige serve --bind 0.0.0.0:0as a subprocess withauth.enabled = falsevia a tempvestige.toml. - Assert: non-zero exit, stderr contains
refusing to bind, no listener ever opens (confirm by trying to connect to the configured port and expecting connection refused after a short timeout).
- Spawn
-
phase_3/cli_keys_create_list_revoke.rs:- Spawn the
vestigeCLI binary with--data-dir <tmp>. - Run
vestige keys create --label test --scopes read,write; capture stdout (the plaintext) and stderr (the human summary). Assertvst_prefix in stdout. - Run
vestige keys list; assert no plaintext, labeltestpresent. - Run
vestige keys revoke <prefix>; confirm exit 0. - Run
vestige keys list; assert label no longer visible without--all.
- Spawn the
-
phase_3/dashboard_login_flow.rs:- Full loop: login -> fetch
/dashboard(gets SPA index, unauthed ok) -> fetch/api/memories(authed via cookie) -> logout -> fetch/api/memories(401).
- Full loop: login -> fetch
-
phase_3/deprecation_auth_token.rs:- Start the server with
VESTIGE_AUTH_TOKEN=test12345...and no created keys. Send a Bearer request with that token -> 200. Assert stderr log containsdeprecated.
- Start the server with
Smoke test (tests/phase_3/smoke/)
-
remote_mcp_client.sh:#!/usr/bin/env bash set -euo pipefail KEY="${VESTIGE_TEST_KEY:?set me}" HOST="${VESTIGE_HOST:-http://127.0.0.1:3928}" # Initialize a session RESP=$(curl -sS -D /tmp/h -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-11-25", "clientInfo":{"name":"smoke","version":"0"}, "capabilities":{}}}' \ "$HOST/mcp") SID=$(grep -i 'mcp-session-id:' /tmp/h | awk '{print $2}' | tr -d '\r') # tools/list curl -sS -H "Authorization: Bearer $KEY" \ -H "Mcp-Session-Id: $SID" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ "$HOST/mcp" | jq '.result.tools | length' echo "smoke ok"
Acceptance Criteria
cargo build -p vestige-mcp-- zero warnings, all feature combinations (--no-default-features, default,--features ort-dynamic).cargo clippy --workspace --all-targets --all-features -- -D warnings.cargo fmt --all --check.- All
tests/phase_3/*.rspass, plus phase_1 and phase_2 remain green. - Unauth request to
POST /mcpreturns 401 withContent-Type: application/problem+jsonand a body containingstatus,title,detail. - Binding
0.0.0.0:<port>with[auth].enabled = falsemakes the process exit with code 2 and printrefusing to bindto stderr. vestige keys create --label Xprints exactly one line on stdout matching^vst_[A-Za-z0-9_-]+$;vestige keys listnever prints that line back.- Dashboard login from a browser-like client (tested via the reqwest
Client::cookie_store(true)harness) yields aSet-CookiewithHttpOnly,SameSite=Strict,Path=/, and Max-Age present. - A second machine can run a curl-based MCP client against the server
(smoke test) and receive successful
tools/listresponses. VESTIGE_AUTH_TOKENstill works and emits the deprecation warning.tests/phase_3/auth_domain_filter.rsdemonstrates that a key scoped todevcannot readhome-domain memories via any of the three auth modes and cannot escape withX-Vestige-Domain.
Rollback Notes
- Ship behind an on-by-default Cargo feature
http-serveronvestige-mcp. Disabling it reverts to stdio + existing localhost HTTP (protocol/http.rsin its current form) with zero behaviour change. - SQL: migration
0300_api_keys.sqlis additive only; rollback is a singleDROP TABLE api_keys;in0300_api_keys.down.sqlfor both backends. Keep a row count safety check in the down migration and log the deletion. - Session secret file: deleting
<data_dir>/session_secretinvalidates every outstanding cookie; users simply log in again. Safe to rotate. - Env var sunset schedule:
- v2.1.x:
VESTIGE_AUTH_TOKENemits a warning, still works. - v2.2.0:
VESTIGE_AUTH_TOKENrefused with an error pointing atvestige keys create.
- v2.1.x:
- Downgrade procedure:
git revertthe Phase 3 merge, then run the down migration. No data loss; plaintext keys were only ever in user-side secret managers.
Open Implementation Questions
-
JSON-RPC library: hand-rolled vs jsonrpsee?
- Candidate A: keep the hand-rolled types in
protocol/types.rsplus the session-awarepost_mcphandler already inprotocol/http.rs. - Candidate B: switch to
jsonrpsee = "0.24"with theserverfeature and adapt it to Axum viajsonrpsee::server::Server.
RECOMMENDATION: A. Phase 3 is about auth and transport surfaces, not library rewrites. The existing types are already correct, tested, and compatible with Streamable HTTP; the 29 cognitive modules depend on
McpServer::handle_request, which does not map 1:1 to jsonrpsee'sRpcModuletrait. Re-evaluate in a future phase only if we need subscription notifications beyond SSE. - Candidate A: keep the hand-rolled types in
-
Streamable HTTP vs plain POST-with-JSON?
- The MCP spec titled "Streamable HTTP" defines:
POST /mcpfor request/response,GET /mcpfor SSE where the client subscribes to server-initiated messages, and anMcp-Session-Idheader for session correlation. The current implementation already covers POST + session header + DELETE; Phase 3 adds the GET/SSE half.
RECOMMENDATION: implement the full Streamable HTTP transport. Long-running tools (dream, consolidate, discover) benefit from SSE progress events, and Claude Desktop / Claude Code both speak Streamable HTTP natively. Keeping POST-only would work for short calls but block the UX we want for background jobs.
- The MCP spec titled "Streamable HTTP" defines:
-
Session cookie crate?
- Candidate A:
axum-extra::extract::cookie::SignedCookieJarwith a 64-byteKey. - Candidate B:
tower-sessions = "0.13"with theMemoryStoreorPostgresStoresession backend. - Candidate C: stateless JWT via
jsonwebtoken.
RECOMMENDATION: A. We do not need server-side session state (the
api_keysrow is the state; the cookie is merely a signed pointer to it). B adds a whole storage backend we do not need. C adds signing-algorithm surface area and revocation becomes awkward ("revoked key" with a long-lived JWT).SignedCookieJargives us HMAC-signed cookies for free, integrates with axum extractors, and the payload is tiny. - Candidate A:
-
Key format and length?
- 22 random bytes base64url-no-pad = 176 bits entropy, encoded ~30 chars,
full key ~34 chars with the
vst_prefix. Long enough to make brute-force infeasible, short enough to paste into config files. - Alternatives: 32 bytes (40 chars, overkill), 16 bytes (128 bits, marginal for secret material shared over networks).
RECOMMENDATION: 22 bytes. Prefix
vst_is already documented in the PRD and gives grep-ability. - 22 random bytes base64url-no-pad = 176 bits entropy, encoded ~30 chars,
full key ~34 chars with the
-
Rate limiting: in scope for Phase 3?
- Useful: mitigates slow brute force, runaway agents.
- Expensive to design well (per-key, per-IP, per-endpoint).
RECOMMENDATION: OUT of scope. Track as
docs/adr/0002-rate-limiting.mdfollow-up. Axum +towerhasConcurrencyLimitLayer(already used); a follow-up can addgovernorortower_governorbehind the auth layer so identity is available. -
CORS policy defaults for dashboard in server mode?
- Candidate A: allow only origins derived from
server.bindhost + the dashboard port. - Candidate B: allow user-listed origins via
server.allowed_originsconfig, with A as fallback. - Candidate C: open CORS to
*when TLS is configured.
RECOMMENDATION: B. Auto-populate
allowed_originsfrom the bind address and dashboard port at start time; if the operator sets the config list, use that list verbatim. Never*(allow_credentials = trueis incompatible with*anyway). - Candidate A: allow only origins derived from
-
Dashboard session lifetime?
- 8 hours for default; configurable via
auth.session_ttl_hours. - Rotate on each write? (Rolling sessions.)
RECOMMENDATION: 8 hours fixed, non-rolling. Revisit if users complain.
- 8 hours for default; configurable via
-
Handling
tools/callscope granularity?- Today,
tools/callis a single MCP method. Read-only tools likesearch,deep_reference,predictshould be callable with a read-only key.
RECOMMENDATION: map tool names to scopes in
McpServer::handle_tools_call. Read-only names:search,session_context,memorywith action in{get, state, get_batch},deep_reference,cross_reference,predict,explore_connections,find_duplicates,memory_timeline,memory_changelog,memory_health,memory_graph,importance_score,system_status. Everything else requireswrite. If a read-only key calls a write tool, return a JSON-RPC error with code-32003("server not initialized" is close but wrong; reuse-32603 internalwith a descriptive message or add a new-32004 UnauthorizedTool). RECOMMEND adding-32004. - Today,
-
How to bridge
MemoryStoretrait with dashboard state (AppState)?- Today
AppState.storage: Arc<Storage>is a concrete type. - Phase 2 introduces
Arc<dyn MemoryStore>.
RECOMMENDATION: in Phase 3, introduce
AppCtx { store: Arc<dyn MemoryStore>, cognitive, config, event_tx }as the single state type for the unified router. KeepAppStateas a thin wrapper (or alias) if the dashboard handlers need to stay untouched in this phase. Migrate the dashboard handlers to the trait in a follow-up refactor to contain the blast radius. - Today
-
Windows support for
session_secretandauth_tokenfile modes?- Unix gets
0600viaOpenOptionsExt. - Windows has no direct equivalent; ACLs differ.
RECOMMENDATION: document the limitation; use default permissions on Windows. Add a
#[cfg(windows)]placeholder to set owner-only ACLs viawindows-aclin a follow-up, not Phase 3. - Unix gets
Critical Files for Implementation
- /home/delandtj/prppl/vestige/crates/vestige-mcp/src/protocol/http.rs
- /home/delandtj/prppl/vestige/crates/vestige-mcp/src/dashboard/mod.rs
- /home/delandtj/prppl/vestige/crates/vestige-mcp/src/main.rs
- /home/delandtj/prppl/vestige/crates/vestige-mcp/src/bin/cli.rs
- /home/delandtj/prppl/vestige/crates/vestige-mcp/Cargo.toml