mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-25 19:15:14 +02:00
1436 lines
57 KiB
Markdown
1436 lines
57 KiB
Markdown
|
|
# 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.)
|
||
|
|
|
||
|
|
### 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 `<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
|