vestige/docs/plans/0003-phase-3-network-access.md
Jan De Landtsheer 0d273c5641
docs: ADR 0001 + Phase 1-4 implementation plans
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
2026-04-22 12:10:24 +02:00

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) 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 <key> then X-API-Key: <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 <data_dir>/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<dyn MemoryStore>.

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<Identity> 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<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.)

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 <data_dir>/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<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.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<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:

  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<dyn MemoryStore + ApiKeyStore>, 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 <tmp>.
    • 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 <prefix>; 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:<port> 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 <data_dir>/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<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. 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