# 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) and `GET /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_keys` table + enforcement (blake3-hashed, scopes `read`/`write`, optional `domain_filter` TEXT[], `last_used` timestamp, `active` flag, revocation). - Auth middleware with three resolution paths in priority order: `Authorization: Bearer ` then `X-API-Key: ` then signed session cookie. All three resolve to the same `ApiKeyIdentity`. - Signed session cookie: `vestige_session`, SameSite=Strict, HttpOnly, Secure-when-TLS, Path=/, Max-Age 8 hours. Signed with HMAC-SHA256 using a key derived from `VESTIGE_SESSION_SECRET` (env) or generated + persisted to `/session_secret` on first boot. - `vestige keys create|list|revoke` CLI subcommand (plus `keys rotate` as a convenience alias of `revoke` + `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/login` with `{"api_key":"vst_..."}` JSON body, `X-API-Key` header, or form body; sets signed cookie; returns 200 JSON `{"ok":true}` for XHR or 303 to `/` if form. Logout at `POST /dashboard/logout` clears cookie. - Per-key `domain_filter` enforced inside the auth layer: if the key has `domain_filter = ["dev","infra"]`, every handler that searches or lists sees the filter pre-applied via a request extension. Optional `X-Vestige-Domain: home` header may narrow further but may never escape the key's filter. - `[server]` and `[auth]` sections in `vestige.toml`, plus backward-compatible env var bridges. - `VESTIGE_AUTH_TOKEN` continues 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_used` write-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_key` pair is documented but its implementation may be deferred behind a `tls` Cargo 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` / `write` scopes. ## Prerequisites Phase 1 artifacts: - `vestige_core::storage::MemoryStore` trait (with `Send` variant via `trait_variant::make`). - `Embedder` trait. - `SqliteMemoryStore` implementing `MemoryStore`. Phase 2 artifacts: - `PgMemoryStore` implementing `MemoryStore`. - `crates/vestige-core/migrations/postgres/` sqlx migrations; `api_keys` table schema present but enforcement path is Phase 3's job. - Runtime backend selection via `vestige.toml` `[storage]` section returning an `Arc`. Assumed already available in workspace: - `axum = 0.8` (currently pinned in `crates/vestige-mcp/Cargo.toml`). - `tower = 0.5`, `tower-http = 0.6` (`cors`, `set-header` features 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"` with `std_rng` (for key bytes; prefer `rand::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 if `axum-extra`'s `SignedCookieJar` is used, but retained for the pure-token-signing path). RECOMMENDATION: rely solely on `axum-extra::extract::cookie::{Key, SignedCookieJar}`. - `tower-http` features bump: add `trace` and `request-id`. - `async-stream = "0.3"` -- emitting SSE events from async closures. - `futures-util` already present -- for `Stream` adapters. - `base64 = "0.22"` -- emitting / parsing the random bytes in the `vst_...` prefix. Use the `URL_SAFE_NO_PAD` alphabet. - `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 1. `crates/vestige-mcp/src/auth/` module (new). Houses key generation, key verification, identity resolution, scopes, domain-filter extractor, session key type, and error types. 2. `crates/vestige-mcp/src/auth/keys.rs` -- key format, generation, blake3 hashing, store-facing trait methods for list / create / revoke / verify. 3. `crates/vestige-mcp/src/auth/middleware.rs` -- axum `from_fn` middleware that populates `Extension` on the request, rejects unauthenticated requests with 401, insufficient scope with 403. 4. `crates/vestige-mcp/src/auth/session.rs` -- `SignedCookieJar` integration, `session_key()` loader (env or persisted file), `issue_session()` and `revoke_session()` helpers. 5. `crates/vestige-mcp/src/http/` module split out of `protocol/http.rs`: - `http/mcp.rs` -- MCP JSON-RPC endpoint (adapted from the current `post_mcp` / `delete_mcp`, with auth middleware now gating). - `http/mcp_sse.rs` -- SSE handler for `GET /mcp` long-running ops. - `http/rest.rs` -- `/api/v1/*` handlers. - `http/mod.rs` -- `build_router()`, `start_server()`, bind-safety check, layer stack assembly. 6. `crates/vestige-mcp/src/http/errors.rs` -- uniform `ApiError` enum and `IntoResponse` implementation. Maps to RFC 7807 problem+json for REST and plain JSON for `/mcp`. 7. Dashboard patch: `crates/vestige-mcp/src/dashboard/mod.rs` -- add the auth middleware to the dashboard router, add `/dashboard/login` + `/dashboard/logout` endpoints, keep `/api/health` unauthenticated. 8. `crates/vestige-mcp/src/bin/cli.rs` -- new `Keys` subcommand group (`create`, `list`, `revoke`, `rotate`). 9. `crates/vestige-mcp/src/config.rs` (new file) -- typed `ServerConfig`, `AuthConfig`, `StorageConfig` loader from `vestige.toml`, merging env var overrides, validating the non-loopback + auth-disabled combination. 10. SQL migration `crates/vestige-core/migrations/postgres/0300_api_keys_enforcement.sql` and SQLite equivalent `crates/vestige-core/migrations/sqlite/0300_api_keys.sql`: - `api_keys` table (if not already created in Phase 2), with `key_hash` UNIQUE, `label` NOT NULL, `scopes` TEXT[] default `{read,write}`, `domain_filter` TEXT[] default `{}`, `created_at`, `last_used`, `active BOOLEAN DEFAULT true`. - Index on `key_hash` (unique already), and on `active WHERE active`. 11. `MemoryStore` trait 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`. 12. Docs updates: - `docs/env-vars.md` (new) -- one sheet for all runtime env vars. - `README.md` server-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.rs` - `crates/vestige-mcp/src/auth/keys.rs` - `crates/vestige-mcp/src/auth/session.rs` - `crates/vestige-mcp/src/auth/middleware.rs` - `crates/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 the `vst_` 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 { 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, pub domain_filter: Vec, pub created_at: chrono::DateTime, pub last_used: Option>, 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>; async fn list_api_keys(&self) -> anyhow::Result>; async fn revoke_api_key(&self, id: uuid::Uuid) -> anyhow::Result; 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), `Secure` when the server is running behind TLS (detected from `config.server.tls_cert.is_some()` or the `X-Forwarded-Proto` trusted header; default: set `Secure` whenever `config.server.bind` is non-loopback). - Payload: serialized `SessionClaims { key_id: Uuid, issued_at: i64, expires_at: i64 }` encoded as `serde_json` then base64url. The signing is handled by `axum-extra::extract::cookie::SignedCookieJar` (HMAC via a 64-byte `Key`). Any tampering or truncation is rejected by the jar automatically. - Key material: 64 random bytes, stored at `/session_secret` (mode 0600) or overridden by `VESTIGE_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 { // 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 { 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, pub domain_filter: Vec, 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>, 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 { 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 { 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; impl axum::extract::FromRequestParts for RequireScope where S: Send + Sync, { type Rejection = AuthError; async fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, ) -> Result { let id = parts.extensions.get::().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>, 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>, Extension(id): Extension, headers: HeaderMap, Json(request): Json, ) -> 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`. 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, // optional operation correlation id } pub async fn get_mcp_sse( State(ctx): State>, Extension(_id): Extension, Query(params): Query, ) -> Result>>, 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> { 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, pub tags: Option>, pub source: Option, pub metadata: Option, } #[derive(serde::Serialize)] pub struct MemoryView { /* flat projection of MemoryRecord */ } pub async fn create_memory( State(ctx): State>, Extension(id): Extension, _: RequireScope, Json(req): Json, ) -> Result<(StatusCode, Json), 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>, Extension(id): Extension, _: RequireScope, headers: HeaderMap, Json(req): Json, ) -> Result, 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, #[serde(default)] pub tls_key: Option, #[serde(default)] pub allowed_origins: Vec, } #[derive(Debug, Clone, serde::Deserialize)] pub struct AuthConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] pub session_secret_file: Option, } impl ServerConfig { pub fn parsed_bind(&self) -> anyhow::Result { 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.bind` host part. - `VESTIGE_HTTP_PORT` (existing) -> `server.bind` port part. - `VESTIGE_DASHBOARD_PORT` (existing) -> `server.dashboard_port`. - `VESTIGE_AUTH_TOKEN` (deprecated) -- when set, synthesize a virtual `ApiKeyRecord` with `id = 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, jar: SignedCookieJar, headers: HeaderMap, body: Option>, ) -> Result<(SignedCookieJar, Json), 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::>(), "domains": rec.domain_filter, })))) } pub async fn logout(jar: SignedCookieJar) -> (SignedCookieJar, Json) { (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, /// Restrict the key to listed domains (comma-separated). Empty = all domains. #[arg(long, value_delimiter = ',')] domains: Vec, }, /// 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 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::>().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: 1. `Config::load()` reads `vestige.toml` (if present) and overlays env vars. 2. Run `enforce_bind_safety(&cfg.server, &cfg.auth)` before spawning any listener. On failure, print to stderr and exit 2. 3. Build `AppCtx` with `Arc`, `CognitiveEngine`, event bus, `session_key`, `config`. 4. `build_router(ctx)` returns a single Axum `Router` that covers MCP, REST, and dashboard. 5. `axum::serve(listener, app).await`. 6. 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.md` new: 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.md` for: 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()` -- asserts `vst_` 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 with `exp = iat - 60` and confirm `claims_from` returns `None`. - `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, send `initialize` over `POST /mcp` with `Authorization: Bearer vst_...`, follow with `tools/list`, assert we see the expected tool count (greater than 20). - `phase_3/http_sse_stream.rs` -- `POST /api/v1/consolidate` returns 202 + `session_id`. `GET /mcp?op=consolidate&session=...` streams at least one `progress` and one `done` event. Use `eventsource-client` dev 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/stats` returns 401 and `Content-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. - `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/login` with valid key -> 200 + `Set-Cookie: vestige_session=...; HttpOnly; SameSite=Strict; Path=/`. - reuse cookie: `GET /api/v1/stats` returns 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/search` without header -> search is scoped to `["dev"]` (insert fixtures with two domains, assert only `dev` rows returned). - `X-Vestige-Domain: dev` -> same. - `X-Vestige-Domain: home` -> 403 with detail `domain not permitted`. - Key with empty filter + `X-Vestige-Domain: dev` -> scoped to `["dev"]`. - Key with empty filter + no header -> no scoping. - `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. - `phase_3/bind_safety_nonlocalhost_without_auth.rs`: - Spawn `vestige serve --bind 0.0.0.0:0` as a subprocess with `auth.enabled = false` via a temp `vestige.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). - `phase_3/cli_keys_create_list_revoke.rs`: - Spawn the `vestige` CLI binary with `--data-dir `. - Run `vestige keys create --label test --scopes read,write`; capture stdout (the plaintext) and stderr (the human summary). Assert `vst_` prefix in stdout. - Run `vestige keys list`; assert no plaintext, label `test` present. - Run `vestige keys revoke `; confirm exit 0. - Run `vestige keys list`; assert label no longer visible without `--all`. - `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). - `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 contains `deprecated`. ### 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/*.rs` pass, plus phase_1 and phase_2 remain green. - [ ] Unauth request to `POST /mcp` returns 401 with `Content-Type: application/problem+json` and a body containing `status`, `title`, `detail`. - [ ] Binding `0.0.0.0:` with `[auth].enabled = false` makes the process exit with code 2 and print `refusing to bind` to stderr. - [ ] `vestige keys create --label X` prints exactly one line on stdout matching `^vst_[A-Za-z0-9_-]+$`; `vestige keys list` never prints that line back. - [ ] Dashboard login from a browser-like client (tested via the reqwest `Client::cookie_store(true)` harness) yields a `Set-Cookie` with `HttpOnly`, `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/list` responses. - [ ] `VESTIGE_AUTH_TOKEN` still works and emits the deprecation warning. - [ ] `tests/phase_3/auth_domain_filter.rs` demonstrates that a key scoped to `dev` cannot read `home`-domain memories via any of the three auth modes and cannot escape with `X-Vestige-Domain`. ## Rollback Notes - Ship behind an on-by-default Cargo feature `http-server` on `vestige-mcp`. Disabling it reverts to stdio + existing localhost HTTP (`protocol/http.rs` in its current form) with zero behaviour change. - SQL: migration `0300_api_keys.sql` is additive only; rollback is a single `DROP TABLE api_keys;` in `0300_api_keys.down.sql` for both backends. Keep a row count safety check in the down migration and log the deletion. - Session secret file: deleting `/session_secret` invalidates every outstanding cookie; users simply log in again. Safe to rotate. - Env var sunset schedule: - v2.1.x: `VESTIGE_AUTH_TOKEN` emits a warning, still works. - v2.2.0: `VESTIGE_AUTH_TOKEN` refused with an error pointing at `vestige keys create`. - Downgrade procedure: `git revert` the Phase 3 merge, then run the down migration. No data loss; plaintext keys were only ever in user-side secret managers. ## Open Implementation Questions 1. JSON-RPC library: hand-rolled vs jsonrpsee? - Candidate A: keep the hand-rolled types in `protocol/types.rs` plus the session-aware `post_mcp` handler already in `protocol/http.rs`. - Candidate B: switch to `jsonrpsee = "0.24"` with the `server` feature and adapt it to Axum via `jsonrpsee::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's `RpcModule` trait. Re-evaluate in a future phase only if we need subscription notifications beyond SSE. 2. Streamable HTTP vs plain POST-with-JSON? - The MCP spec titled "Streamable HTTP" defines: `POST /mcp` for request/response, `GET /mcp` for SSE where the client subscribes to server-initiated messages, and an `Mcp-Session-Id` header 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. 3. Session cookie crate? - Candidate A: `axum-extra::extract::cookie::SignedCookieJar` with a 64-byte `Key`. - Candidate B: `tower-sessions = "0.13"` with the `MemoryStore` or `PostgresStore` session backend. - Candidate C: stateless JWT via `jsonwebtoken`. RECOMMENDATION: A. We do not need server-side session state (the `api_keys` row 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). `SignedCookieJar` gives us HMAC-signed cookies for free, integrates with axum extractors, and the payload is tiny. 4. 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. 5. 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.md` follow-up. Axum + `tower` has `ConcurrencyLimitLayer` (already used); a follow-up can add `governor` or `tower_governor` behind the auth layer so identity is available. 6. CORS policy defaults for dashboard in server mode? - Candidate A: allow only origins derived from `server.bind` host + the dashboard port. - Candidate B: allow user-listed origins via `server.allowed_origins` config, with A as fallback. - Candidate C: open CORS to `*` when TLS is configured. RECOMMENDATION: B. Auto-populate `allowed_origins` from the bind address and dashboard port at start time; if the operator sets the config list, use that list verbatim. Never `*` (`allow_credentials = true` is incompatible with `*` anyway). 7. 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. Handling `tools/call` scope granularity? - Today, `tools/call` is a single MCP method. Read-only tools like `search`, `deep_reference`, `predict` should be callable with a read-only key. RECOMMENDATION: map tool names to scopes in `McpServer::handle_tools_call`. Read-only names: `search`, `session_context`, `memory` with 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 requires `write`. 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 internal` with a descriptive message or add a new `-32004 UnauthorizedTool`). RECOMMEND adding `-32004`. 9. How to bridge `MemoryStore` trait with dashboard state (`AppState`)? - Today `AppState.storage: Arc` is a concrete type. - Phase 2 introduces `Arc`. RECOMMENDATION: in Phase 3, introduce `AppCtx { store: Arc, cognitive, config, event_tx }` as the single state type for the unified router. Keep `AppState` as 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. 10. Windows support for `session_secret` and `auth_token` file modes? - Unix gets `0600` via `OpenOptionsExt`. - 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 via `windows-acl` in a follow-up, not Phase 3. ### 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