AkurAI Build
Menu

AkurAI-NotesCommit 705e1ce0

Commit 705e1ce0

705e1ce035ea4b9fb4c413dd38f550650185b571 by Claude Code

diff --git a/Cargo.lock b/Cargo.lock
index f5ec005..24375e8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -31,7 +31,7 @@ version = "0.8.1"
 
 [[package]]
 name = "akurai-notes"
-version = "0.0.4"
+version = "0.0.5"
 dependencies = [
  "akurai-http",
  "akurai-json",
@@ -73,7 +73,7 @@ dependencies = [
 
 [[package]]
 name = "notes-api"
-version = "0.0.4"
+version = "0.0.5"
 dependencies = [
  "akurai-collections",
  "akurai-http",
@@ -89,7 +89,7 @@ dependencies = [
 
 [[package]]
 name = "notes-core"
-version = "0.0.4"
+version = "0.0.5"
 dependencies = [
  "akurai-json",
  "akurai-schema",
@@ -98,7 +98,7 @@ dependencies = [
 
 [[package]]
 name = "notes-mcp"
-version = "0.0.4"
+version = "0.0.5"
 dependencies = [
  "akurai-http",
  "akurai-json",
diff --git a/Cargo.toml b/Cargo.toml
index 7ed8d76..2baf04f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
 members = ["crates/notes-core", "crates/notes-api", "crates/notes-cli", "crates/notes-mcp"]
 
 [workspace.package]
-version = "0.0.4"
+version = "0.0.5"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/olibuijr/AkurAI-Notes"
diff --git a/VERSION b/VERSION
index 81340c7..bbdeab6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.4
+0.0.5
diff --git a/crates/notes-api/src/embed.rs b/crates/notes-api/src/embed.rs
index ae8ea65..c4f2e99 100644
--- a/crates/notes-api/src/embed.rs
+++ b/crates/notes-api/src/embed.rs
@@ -1,5 +1,5 @@
 use akurai_storage::BTree;
-use akurai_vector::{decode, embed, encode, EmbedError};
+use akurai_vector::{decode, embed, embed_with_bearer, encode, EmbedError};
 use notes_core::search::owner_matches;
 
 pub struct EmbedStore<'a> {
@@ -86,10 +86,23 @@ pub fn embed_query(text: &str, url: &str, model: &str) -> Result<Vec<f32>, Embed
     embed(url, model, text)
 }
 
+pub fn embed_query_with_key(
+    text: &str,
+    url: &str,
+    model: &str,
+    api_key: Option<&str>,
+) -> Result<Vec<f32>, EmbedError> {
+    match api_key {
+        Some(token) if !token.trim().is_empty() => embed_with_bearer(url, model, text, token),
+        _ => embed_query(text, url, model),
+    }
+}
+
 pub fn maybe_reembed(
     db: &mut BTree,
     embed_url: &Option<String>,
     embed_model: &Option<String>,
+    embed_api_key: &Option<String>,
     note_id: i64,
     body: &str,
 ) {
@@ -98,7 +111,7 @@ pub fn maybe_reembed(
         None => return,
     };
     let model = embed_model.as_deref().unwrap_or("qwen3-embedding-0.6b");
-    match embed_query(body, url, model) {
+    match embed_query_with_key(body, url, model, embed_api_key.as_deref()) {
         Ok(vec) => {
             let mut store = EmbedStore { db };
             let _ = store.store(note_id, &vec);
diff --git a/crates/notes-api/src/handlers/crud.rs b/crates/notes-api/src/handlers/crud.rs
index 454e736..55b08ab 100644
--- a/crates/notes-api/src/handlers/crud.rs
+++ b/crates/notes-api/src/handlers/crud.rs
@@ -216,6 +216,7 @@ pub fn create_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
                         &mut db,
                         &state_lock.embed_url,
                         &state_lock.embed_model,
+                        &state_lock.embed_api_key,
                         next_id as i64,
                         &body_str,
                     );
@@ -336,6 +337,7 @@ pub fn update_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
                         &mut db,
                         &state_lock.embed_url,
                         &state_lock.embed_model,
+                        &state_lock.embed_api_key,
                         id as i64,
                         &new_body,
                     );
diff --git a/crates/notes-api/src/handlers/search.rs b/crates/notes-api/src/handlers/search.rs
index cbbde80..967f2e8 100644
--- a/crates/notes-api/src/handlers/search.rs
+++ b/crates/notes-api/src/handlers/search.rs
@@ -1,5 +1,5 @@
 use crate::auth::session;
-use crate::embed::{embed_query, EmbedStore};
+use crate::embed::{embed_query_with_key, EmbedStore};
 use crate::handlers::RouteHandler;
 use crate::router_setup::{json_response, upper_bound, NotesState};
 use akurai_http::{Request, Response};
@@ -32,7 +32,7 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
         }
 
         // Extract session and embed config in one locked scope
-        let (user, embed_url, embed_model) = {
+        let (user, embed_url, embed_model, embed_api_key) = {
             let state_lock = match state.lock() {
                 Ok(s) => s,
                 Err(_) => {
@@ -73,13 +73,14 @@ pub fn search_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
                 user,
                 state_lock.embed_url.clone(),
                 state_lock.embed_model.clone(),
+                state_lock.embed_api_key.clone(),
             )
         };
 
         // Try semantic search
         if let Some(ref url) = embed_url {
             let model = embed_model.as_deref().unwrap_or("qwen3-embedding-0.6b");
-            match embed_query(&q, url, model) {
+            match embed_query_with_key(&q, url, model, embed_api_key.as_deref()) {
                 Ok(qv) => {
                     let state_lock = match state.lock() {
                         Ok(s) => s,
diff --git a/crates/notes-api/src/router_setup.rs b/crates/notes-api/src/router_setup.rs
index 3db9437..6e90727 100644
--- a/crates/notes-api/src/router_setup.rs
+++ b/crates/notes-api/src/router_setup.rs
@@ -22,6 +22,7 @@ pub struct NotesState {
     pub frontend_dir: PathBuf,
     pub embed_url: Option<String>,
     pub embed_model: Option<String>,
+    pub embed_api_key: Option<String>,
     pub idp_cfg: IdpConfig,
 }
 
@@ -33,8 +34,15 @@ impl NotesState {
             db: Arc::new(Mutex::new(btree)),
             objects,
             frontend_dir,
-            embed_url: None,
-            embed_model: None,
+            embed_url: std::env::var("AKURAI_NOTES_EMBED_URL")
+                .ok()
+                .filter(|s| !s.trim().is_empty()),
+            embed_model: std::env::var("AKURAI_NOTES_EMBED_MODEL")
+                .ok()
+                .filter(|s| !s.trim().is_empty()),
+            embed_api_key: std::env::var("AKURAI_NOTES_EMBED_API_KEY")
+                .ok()
+                .filter(|s| !s.trim().is_empty()),
             idp_cfg,
         })
     }
diff --git a/crates/notes-api/tests/embed_test.rs b/crates/notes-api/tests/embed_test.rs
index 2aab8ff..2a61ee4 100644
--- a/crates/notes-api/tests/embed_test.rs
+++ b/crates/notes-api/tests/embed_test.rs
@@ -1,7 +1,13 @@
 use akurai_json::Value;
 use akurai_storage::BTree;
-use notes_api::embed::{maybe_reembed, EmbedStore};
+use notes_api::auth::idp_client::IdpConfig;
+use notes_api::embed::{embed_query_with_key, maybe_reembed, EmbedStore};
 use notes_api::NotesState;
+use std::io::{Read, Write};
+use std::net::TcpListener;
+use std::path::PathBuf;
+use std::sync::{Mutex, OnceLock};
+use std::thread;
 
 fn setup_db(name: &str) -> (BTree, String) {
     let db_path = format!("_test_embed_{}.db", name);
@@ -10,6 +16,106 @@ fn setup_db(name: &str) -> (BTree, String) {
     (tree, db_path)
 }
 
+fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+    LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
+}
+
+struct EnvGuard(Vec<(&'static str, Option<String>)>);
+
+impl EnvGuard {
+    fn capture(keys: &[&'static str]) -> Self {
+        Self(
+            keys.iter()
+                .map(|key| (*key, std::env::var(key).ok()))
+                .collect(),
+        )
+    }
+}
+
+impl Drop for EnvGuard {
+    fn drop(&mut self) {
+        for (key, value) in &self.0 {
+            match value {
+                Some(value) => std::env::set_var(key, value),
+                None => std::env::remove_var(key),
+            }
+        }
+    }
+}
+
+fn test_idp_config() -> IdpConfig {
+    IdpConfig {
+        issuer: "https://auth.olibuijr.com".into(),
+        internal_url: "http://127.0.0.1:3500".into(),
+        client_id: "notes-test".into(),
+        client_secret: "secret".into(),
+        redirect_uri: "https://akurai-notes.olibuijr.com/auth/callback".into(),
+        allow: vec!["user@example.com".into()],
+    }
+}
+
+fn spawn_embed_server(expected_auth: Option<&str>) -> (String, thread::JoinHandle<String>) {
+    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+    let addr = format!("http://{}", listener.local_addr().unwrap());
+    let expected_auth = expected_auth.map(str::to_string);
+    let handle = thread::spawn(move || {
+        let (mut stream, _) = listener.accept().unwrap();
+        let mut request = Vec::new();
+        let mut buf = [0_u8; 1024];
+        loop {
+            let read = stream.read(&mut buf).unwrap();
+            if read == 0 {
+                break;
+            }
+            request.extend_from_slice(&buf[..read]);
+            let text = String::from_utf8_lossy(&request);
+            let Some(head_end) = text.find("\r\n\r\n") else {
+                continue;
+            };
+            let content_length = text
+                .lines()
+                .find_map(|line| {
+                    let (name, value) = line.split_once(':')?;
+                    if name.eq_ignore_ascii_case("Content-Length") {
+                        value.trim().parse::<usize>().ok()
+                    } else {
+                        None
+                    }
+                })
+                .unwrap_or(0);
+            if request.len() >= head_end + 4 + content_length {
+                break;
+            }
+        }
+
+        let request_text = String::from_utf8_lossy(&request).to_string();
+        match expected_auth {
+            Some(token) => {
+                let expected = format!("\r\nAuthorization: Bearer {token}\r\n");
+                assert!(
+                    request_text.contains(&expected),
+                    "missing bearer header in request: {request_text}"
+                );
+            }
+            None => assert!(
+                !request_text.contains("\r\nAuthorization: "),
+                "unexpected authorization header in request: {request_text}"
+            ),
+        }
+
+        let body = r#"{"data":[{"embedding":[0.5,0.25,-0.75]}]}"#;
+        let response = format!(
+            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+            body.len(),
+            body
+        );
+        stream.write_all(response.as_bytes()).unwrap();
+        request_text
+    });
+    (addr, handle)
+}
+
 fn make_note_key(id: u64) -> Vec<u8> {
     let mut k = b"notes:".to_vec();
     k.extend_from_slice(&id.to_be_bytes());
@@ -99,7 +205,7 @@ fn maybe_reembed_skips_when_url_is_none() {
     let (mut db, db_path) = setup_db("skip");
     insert_note(&mut db, 1, "Test", "Hello world", "user");
 
-    maybe_reembed(&mut db, &None, &None, 1, "Hello world");
+    maybe_reembed(&mut db, &None, &None, &None, 1, "Hello world");
 
     let mut store = EmbedStore { db: &mut db };
     assert!(store.load(1).is_none());
@@ -107,6 +213,92 @@ fn maybe_reembed_skips_when_url_is_none() {
     std::fs::remove_file(&db_path).ok();
 }
 
+#[test]
+fn embed_query_with_key_sends_bearer_token() {
+    let (embed_url, handle) = spawn_embed_server(Some("router-secret"));
+
+    let vec = embed_query_with_key(
+        "secure query",
+        &embed_url,
+        "test-embed-model",
+        Some("router-secret"),
+    )
+    .unwrap();
+
+    assert_eq!(vec, vec![0.5, 0.25, -0.75]);
+    let request = handle.join().unwrap();
+    assert!(request.contains(r#""model":"test-embed-model""#));
+    assert!(request.contains(r#""input":"secure query""#));
+}
+
+#[test]
+fn embed_query_with_key_skips_bearer_for_blank_token() {
+    let (embed_url, handle) = spawn_embed_server(None);
+
+    let vec =
+        embed_query_with_key("plain query", &embed_url, "test-embed-model", Some("   ")).unwrap();
+
+    assert_eq!(vec, vec![0.5, 0.25, -0.75]);
+    let _ = handle.join().unwrap();
+}
+
+#[test]
+fn notes_state_reads_embed_env_values() {
+    let _lock = env_lock();
+    let _env = EnvGuard::capture(&[
+        "AKURAI_NOTES_EMBED_URL",
+        "AKURAI_NOTES_EMBED_MODEL",
+        "AKURAI_NOTES_EMBED_API_KEY",
+    ]);
+    std::env::set_var(
+        "AKURAI_NOTES_EMBED_URL",
+        "http://127.0.0.1:4219/v1/embeddings",
+    );
+    std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "intfloat/multilingual-e5-small");
+    std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "router-secret");
+
+    let db_path = format!("_test_state_embed_{}.db", std::process::id());
+    let _ = std::fs::remove_file(&db_path);
+    let state = NotesState::new(&db_path, PathBuf::from("site/frontend"), test_idp_config())
+        .expect("state should build");
+
+    assert_eq!(
+        state.embed_url.as_deref(),
+        Some("http://127.0.0.1:4219/v1/embeddings")
+    );
+    assert_eq!(
+        state.embed_model.as_deref(),
+        Some("intfloat/multilingual-e5-small")
+    );
+    assert_eq!(state.embed_api_key.as_deref(), Some("router-secret"));
+
+    std::fs::remove_file(&db_path).ok();
+}
+
+#[test]
+fn notes_state_ignores_blank_embed_env_values() {
+    let _lock = env_lock();
+    let _env = EnvGuard::capture(&[
+        "AKURAI_NOTES_EMBED_URL",
+        "AKURAI_NOTES_EMBED_MODEL",
+        "AKURAI_NOTES_EMBED_API_KEY",
+    ]);
+    std::env::set_var("AKURAI_NOTES_EMBED_URL", "   ");
+    std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "");
+    std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "\t");
+
+    let db_path = format!("_test_state_embed_blank_{}.db", std::process::id());
+    let _ = std::fs::remove_file(&db_path);
+    let state = NotesState::new(&db_path, PathBuf::from("site/frontend"), test_idp_config())
+        .expect("state should build");
+
+    assert_eq!(state.embed_url, None);
+    assert_eq!(state.embed_model, None);
+    assert_eq!(state.embed_api_key, None);
+
+    std::fs::remove_file(&db_path).ok();
+}
+
 #[test]
 fn substring_search_via_prefix_scan() {
     let (mut db, db_path) = setup_db("substr");
@@ -165,7 +357,7 @@ fn create_note_stores_embedding() {
     let embed_url = std::env::var("AKURAI_NOTES_EMBED_URL").ok();
     let embed_model = std::env::var("AKURAI_NOTES_EMBED_MODEL").ok();
 
-    maybe_reembed(&mut db, &embed_url, &embed_model, note_id, body);
+    maybe_reembed(&mut db, &embed_url, &embed_model, &None, note_id, body);
 
     let mut store = EmbedStore { db: &mut db };
     let loaded = store.load(note_id);
diff --git a/crates/notes-cli/src/cmd_mcp.rs b/crates/notes-cli/src/cmd_mcp.rs
index bdcc076..60be20e 100644
--- a/crates/notes-cli/src/cmd_mcp.rs
+++ b/crates/notes-cli/src/cmd_mcp.rs
@@ -7,6 +7,7 @@ pub struct McpRunConfig {
     pub token: String,
     pub embed_url: String,
     pub embed_model: String,
+    pub embed_api_key: String,
     pub owner_sub: String,
 }
 
@@ -26,6 +27,11 @@ pub fn run(cfg: McpRunConfig) -> Result<(), Box<dyn std::error::Error>> {
         } else {
             Some(cfg.embed_model)
         },
+        embed_api_key: if cfg.embed_api_key.is_empty() {
+            None
+        } else {
+            Some(cfg.embed_api_key)
+        },
         owner_sub: cfg.owner_sub,
     };
     let server = McpServer::new(mcp_cfg)?;
diff --git a/crates/notes-cli/src/main.rs b/crates/notes-cli/src/main.rs
index 38bdb55..659acf2 100644
--- a/crates/notes-cli/src/main.rs
+++ b/crates/notes-cli/src/main.rs
@@ -90,6 +90,7 @@ fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
     let mut token = String::new();
     let mut embed_url = String::new();
     let mut embed_model = String::new();
+    let mut embed_api_key = String::new();
     let mut owner_sub = String::new();
 
     let mut i = 0;
@@ -105,6 +106,7 @@ fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
             "--token" => token = next(args, &mut i, "--token")?,
             "--embed-url" => embed_url = next(args, &mut i, "--embed-url")?,
             "--embed-model" => embed_model = next(args, &mut i, "--embed-model")?,
+            "--embed-api-key" => embed_api_key = next(args, &mut i, "--embed-api-key")?,
             "--owner-sub" => owner_sub = next(args, &mut i, "--owner-sub")?,
             other => return Err(format!("unknown flag '{other}'")),
         }
@@ -117,6 +119,15 @@ fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
     if owner_sub.is_empty() {
         owner_sub = std::env::var("AKURAI_NOTES_MCP_OWNER_SUB").unwrap_or_default();
     }
+    if embed_url.is_empty() {
+        embed_url = std::env::var("AKURAI_NOTES_EMBED_URL").unwrap_or_default();
+    }
+    if embed_model.is_empty() {
+        embed_model = std::env::var("AKURAI_NOTES_EMBED_MODEL").unwrap_or_default();
+    }
+    if embed_api_key.is_empty() {
+        embed_api_key = std::env::var("AKURAI_NOTES_EMBED_API_KEY").unwrap_or_default();
+    }
 
     Ok(cmd_mcp::McpRunConfig {
         host,
@@ -125,6 +136,7 @@ fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
         token,
         embed_url,
         embed_model,
+        embed_api_key,
         owner_sub,
     })
 }
@@ -155,5 +167,93 @@ fn print_help() {
     println!("  --token <tok>      Bearer token (env: AKURAI_NOTES_MCP_TOKEN)");
     println!("  --embed-url <url>  Embedding API URL");
     println!("  --embed-model <m>  Embedding model name");
+    println!("  --embed-api-key <k> Embedding bearer token");
     println!("  --owner-sub <sub>  Owner subject (env: AKURAI_NOTES_MCP_OWNER_SUB)");
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::sync::{Mutex, OnceLock};
+
+    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
+    }
+
+    struct EnvGuard(Vec<(&'static str, Option<String>)>);
+
+    impl EnvGuard {
+        fn capture(keys: &[&'static str]) -> Self {
+            Self(
+                keys.iter()
+                    .map(|key| (*key, std::env::var(key).ok()))
+                    .collect(),
+            )
+        }
+    }
+
+    impl Drop for EnvGuard {
+        fn drop(&mut self) {
+            for (key, value) in &self.0 {
+                match value {
+                    Some(value) => std::env::set_var(key, value),
+                    None => std::env::remove_var(key),
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn parse_mcp_reads_embed_env_when_flags_are_absent() {
+        let _lock = env_lock();
+        let _env = EnvGuard::capture(&[
+            "AKURAI_NOTES_EMBED_URL",
+            "AKURAI_NOTES_EMBED_MODEL",
+            "AKURAI_NOTES_EMBED_API_KEY",
+        ]);
+        std::env::set_var(
+            "AKURAI_NOTES_EMBED_URL",
+            "http://127.0.0.1:4219/v1/embeddings",
+        );
+        std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "intfloat/multilingual-e5-small");
+        std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "router-secret");
+
+        let args: Vec<String> = Vec::new();
+        let cfg = parse_mcp(&args).unwrap();
+
+        assert_eq!(cfg.embed_url, "http://127.0.0.1:4219/v1/embeddings");
+        assert_eq!(cfg.embed_model, "intfloat/multilingual-e5-small");
+        assert_eq!(cfg.embed_api_key, "router-secret");
+    }
+
+    #[test]
+    fn parse_mcp_prefers_embed_flags_over_env() {
+        let _lock = env_lock();
+        let _env = EnvGuard::capture(&[
+            "AKURAI_NOTES_EMBED_URL",
+            "AKURAI_NOTES_EMBED_MODEL",
+            "AKURAI_NOTES_EMBED_API_KEY",
+        ]);
+        std::env::set_var(
+            "AKURAI_NOTES_EMBED_URL",
+            "http://127.0.0.1:4219/v1/embeddings",
+        );
+        std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "env-model");
+        std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "env-secret");
+
+        let args = vec![
+            "--embed-url".to_string(),
+            "http://127.0.0.1:7777/v1/embeddings".to_string(),
+            "--embed-model".to_string(),
+            "flag-model".to_string(),
+            "--embed-api-key".to_string(),
+            "flag-secret".to_string(),
+        ];
+        let cfg = parse_mcp(&args).unwrap();
+
+        assert_eq!(cfg.embed_url, "http://127.0.0.1:7777/v1/embeddings");
+        assert_eq!(cfg.embed_model, "flag-model");
+        assert_eq!(cfg.embed_api_key, "flag-secret");
+    }
+}
diff --git a/crates/notes-mcp/src/server.rs b/crates/notes-mcp/src/server.rs
index 1409c11..b6ef6b8 100644
--- a/crates/notes-mcp/src/server.rs
+++ b/crates/notes-mcp/src/server.rs
@@ -10,6 +10,7 @@ pub struct McpConfig {
     pub token: String,
     pub embed_url: Option<String>,
     pub embed_model: Option<String>,
+    pub embed_api_key: Option<String>,
     pub owner_sub: String,
 }
 
@@ -26,6 +27,7 @@ impl McpServer {
             Arc::new(Mutex::new(btree)),
             config.embed_url.clone(),
             config.embed_model.clone(),
+            config.embed_api_key.clone(),
             config.owner_sub.clone(),
         );
         Ok(Self {
diff --git a/crates/notes-mcp/src/tools.rs b/crates/notes-mcp/src/tools.rs
index 233cc79..880eec7 100644
--- a/crates/notes-mcp/src/tools.rs
+++ b/crates/notes-mcp/src/tools.rs
@@ -1,6 +1,6 @@
 use akurai_json::Value;
 use akurai_storage::BTree;
-use notes_api::embed::{embed_query, EmbedStore};
+use notes_api::embed::{embed_query_with_key, EmbedStore};
 use notes_core::search as note_search;
 use std::sync::{Arc, Mutex};
 
@@ -8,6 +8,7 @@ pub struct ToolRegistry {
     pub db: Arc<Mutex<BTree>>,
     pub embed_url: Option<String>,
     pub embed_model: Option<String>,
+    pub embed_api_key: Option<String>,
     pub owner_sub: String,
 }
 
@@ -16,12 +17,14 @@ impl ToolRegistry {
         db: Arc<Mutex<BTree>>,
         embed_url: Option<String>,
         embed_model: Option<String>,
+        embed_api_key: Option<String>,
         owner_sub: String,
     ) -> Self {
         Self {
             db,
             embed_url,
             embed_model,
+            embed_api_key,
             owner_sub,
         }
     }
@@ -414,7 +417,7 @@ impl ToolRegistry {
                 .embed_model
                 .as_deref()
                 .unwrap_or("qwen3-embedding-0.6b");
-            if let Ok(qv) = embed_query(query, url, model) {
+            if let Ok(qv) = embed_query_with_key(query, url, model, self.embed_api_key.as_deref()) {
                 let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
                 let mut store = EmbedStore { db: &mut db };
                 let owners = [self.owner_sub()];
diff --git a/crates/notes-mcp/tests/mcp_test.rs b/crates/notes-mcp/tests/mcp_test.rs
index 11a7e14..41f354d 100644
--- a/crates/notes-mcp/tests/mcp_test.rs
+++ b/crates/notes-mcp/tests/mcp_test.rs
@@ -1,7 +1,19 @@
 use akurai_storage::BTree;
+use notes_api::embed::EmbedStore;
+use std::io::{Read, Write};
+use std::net::TcpListener;
 use std::sync::{Arc, Mutex};
+use std::thread;
 
 fn make_registry() -> notes_mcp::tools::ToolRegistry {
+    make_registry_with_embed(None, None, None)
+}
+
+fn make_registry_with_embed(
+    embed_url: Option<String>,
+    embed_model: Option<String>,
+    embed_api_key: Option<String>,
+) -> notes_mcp::tools::ToolRegistry {
     let dir = std::env::temp_dir().join(format!("notes-mcp-test-{}", std::process::id()));
     let _ = std::fs::create_dir_all(&dir);
     let unique = format!(
@@ -14,7 +26,74 @@ fn make_registry() -> notes_mcp::tools::ToolRegistry {
     let db_path = dir.join(unique);
     let db = BTree::open(&db_path).expect("create test db");
     let owner_sub = "testuser@example.com".to_string();
-    notes_mcp::tools::ToolRegistry::new(Arc::new(Mutex::new(db)), None, None, owner_sub)
+    notes_mcp::tools::ToolRegistry::new(
+        Arc::new(Mutex::new(db)),
+        embed_url,
+        embed_model,
+        embed_api_key,
+        owner_sub,
+    )
+}
+
+fn spawn_embed_server(expected_auth: &str) -> (String, thread::JoinHandle<String>) {
+    let listener = TcpListener::bind("127.0.0.1:0").expect("bind test embed server");
+    let addr = format!("http://{}", listener.local_addr().unwrap());
+    let expected_auth = expected_auth.to_string();
+    let handle = thread::spawn(move || {
+        let (mut stream, _) = listener.accept().expect("accept test request");
+        let mut request = Vec::new();
+        let mut buf = [0_u8; 1024];
+        loop {
+            let read = stream.read(&mut buf).expect("read request");
+            if read == 0 {
+                break;
+            }
+            request.extend_from_slice(&buf[..read]);
+            let text = String::from_utf8_lossy(&request);
+            let Some(head_end) = text.find("\r\n\r\n") else {
+                continue;
+            };
+            let content_length = text
+                .lines()
+                .find_map(|line| {
+                    let (name, value) = line.split_once(':')?;
+                    if name.eq_ignore_ascii_case("Content-Length") {
+                        value.trim().parse::<usize>().ok()
+                    } else {
+                        None
+                    }
+                })
+                .unwrap_or(0);
+            if request.len() >= head_end + 4 + content_length {
+                break;
+            }
+        }
+
+        let request_text = String::from_utf8_lossy(&request).to_string();
+        let expected = format!("\r\nAuthorization: Bearer {expected_auth}\r\n");
+        assert!(
+            request_text.contains(&expected),
+            "missing bearer header in request: {request_text}"
+        );
+
+        let body = r#"{"data":[{"embedding":[0.9,0.1,0.0]}]}"#;
+        let response = format!(
+            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+            body.len(),
+            body
+        );
+        stream
+            .write_all(response.as_bytes())
+            .expect("write response");
+        request_text
+    });
+    (addr, handle)
+}
+
+fn store_embedding(registry: &notes_mcp::tools::ToolRegistry, note_id: i64, vector: &[f32]) {
+    let mut db = registry.db.lock().unwrap();
+    let mut store = EmbedStore { db: &mut db };
+    store.store(note_id, vector).unwrap();
 }
 
 fn create_note_via_tool(
@@ -161,6 +240,30 @@ fn semantic_search_without_embed_uses_lexical_query() {
     assert!(!resp.contains("Grocery list"), "{resp}");
 }
 
+#[test]
+fn semantic_search_uses_embed_bearer_token() {
+    let (embed_url, handle) = spawn_embed_server("router-secret");
+    let registry = make_registry_with_embed(
+        Some(embed_url),
+        Some("test-embed-model".into()),
+        Some("router-secret".into()),
+    );
+    create_note_via_tool(
+        &registry,
+        "Deploy runbook",
+        "akurai-ec2 ports drift before release",
+        "testuser@example.com",
+    );
+    store_embedding(&registry, 1, &[0.9, 0.1, 0.0]);
+
+    let req = r#"{"jsonrpc":"2.0","id":32,"method":"tools/call","params":{"name":"semantic_search","arguments":{"query":"protected query","k":5}}}"#;
+    let resp = registry.handle_request(req);
+
+    assert!(resp.contains("Deploy runbook"), "{resp}");
+    let request = handle.join().unwrap();
+    assert!(request.contains(r#""model":"test-embed-model""#));
+}
+
 #[test]
 fn get_note_returns_full_note() {
     let registry = make_registry();
diff --git a/deploy.sh b/deploy.sh
index 3fb43a4..0412141 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -1,11 +1,19 @@
 #!/usr/bin/env bash
+# Deploy rules of conduct:
+# - Read AGENTS.md and canonical Notes/docs before changing or deploying.
+# - Never print, commit, or copy secrets; use passvault/env files only.
+# - Preserve env files, service users, user data, and databases; snapshot state before risky swaps.
+# - Verify ports, DNS, TLS, and systemd unit names before changing routes or services.
+# - Use managed services only (systemd/pm2); no nohup, background shells, or ad hoc daemons.
+# - Run gates and health checks; if deploy fails, stop and roll back rather than improvising.
+# - Keep deploy behavior unchanged unless the task explicitly asks for deploy logic changes.
 # deploy.sh — AkurAI-Notes release engine.
 #
 #   ./deploy.sh [patch|minor|major]   release: gate -> bump -> changelog -> tag ->
 #                                      push -> publish live (default: patch)
 #   ./deploy.sh ec2                    publish the live site only (no version bump)
 #
-# A release cuts the repo CHANGELOG.md, tags, pushes, and publishes three
+# A release cuts the repo CHANGELOG.md, tags, pushes, and publishes two
 # systemd services behind two nginx location blocks:
 #   /   -> :8099 (web app + REST API)
 #   /mcp -> :8101 (MCP JSON-RPC server; 8100 is AkurAI-Fulltrui)
@@ -31,7 +39,6 @@ ec2_publish() {
   build_musl
   ship_binary
   ensure_env_file
-  ensure_embedding_model
   install_systemd_units
   write_nginx_vhost
   run_tls
@@ -69,55 +76,64 @@ ensure_env_file() {
 set -euo pipefail
 mkdir -p /etc/akurai-notes
 F=/etc/akurai-notes/notes.env
+PENDING=/etc/akurai-notes/notes.env.pending
+BOOTSTRAP=0
 if [ ! -f "$F" ] || ! grep -q '^AKURAI_NOTES_IDP_CLIENT_ID=' "$F"; then
-  PENDING=/etc/akurai-notes/notes.env.pending
-  touch "$F"; chmod 0600 "$F"
+  BOOTSTRAP=1
+fi
+touch "$F"
+chmod 0600 "$F"
 
+append_if_missing() {
+  key="$1"
+  value="$2"
+  if ! grep -q "^${key}=" "$F"; then
+    printf '%s=%s\n' "$key" "$value" >> "$F"
+    chmod 0600 "$F"
+  fi
+}
+
+set_kv() {
+  key="$1"
+  value="$2"
+  tmp="${F}.tmp"
+  grep -v "^${key}=" "$F" > "$tmp" || true
+  printf '%s=%s\n' "$key" "$value" >> "$tmp"
+  mv "$tmp" "$F"
+  chmod 0600 "$F"
+}
+
+if [ "$BOOTSTRAP" -eq 1 ]; then
   if [ -f "$PENDING" ]; then
-    cat "$PENDING" | tee -a "$F" >/dev/null
+    cat "$PENDING" >> "$F"
   fi
 
-  grep -q '^AKURAI_NOTES_DB='            "$F" || echo "AKURAI_NOTES_DB=/var/lib/akurai-notes/notes.db"           | tee -a "$F" >/dev/null
-  grep -q '^AKURAI_NOTES_IDP_ISSUER='   "$F" || echo 'AKURAI_NOTES_IDP_ISSUER=https://auth.olibuijr.com'         | tee -a "$F" >/dev/null
-  grep -q '^AKURAI_NOTES_IDP_REDIRECT=' "$F" || echo 'AKURAI_NOTES_IDP_REDIRECT=https://akurai-notes.olibuijr.com/auth/callback' | tee -a "$F" >/dev/null
-  grep -q '^AKURAI_NOTES_ALLOW='        "$F" || echo 'AKURAI_NOTES_ALLOW=olibuijr@olibuijr.com'                  | tee -a "$F" >/dev/null
-  grep -q '^AKURAI_NOTES_EMBED_URL='    "$F" || echo 'AKURAI_NOTES_EMBED_URL=http://127.0.0.1:8081/v1/embeddings' | tee -a "$F" >/dev/null
-  grep -q '^AKURAI_NOTES_EMBED_MODEL='  "$F" || echo 'AKURAI_NOTES_EMBED_MODEL=qwen3-embedding-0.6b'              | tee -a "$F" >/dev/null
+  append_if_missing AKURAI_NOTES_DB /var/lib/akurai-notes/notes.db
+  append_if_missing AKURAI_NOTES_IDP_ISSUER https://auth.olibuijr.com
+  append_if_missing AKURAI_NOTES_IDP_REDIRECT https://akurai-notes.olibuijr.com/auth/callback
+  append_if_missing AKURAI_NOTES_ALLOW olibuijr@olibuijr.com
 
   if ! grep -q '^AKURAI_NOTES_MCP_TOKEN=' "$F"; then
     T="mcp_$(openssl rand -hex 32)"
-    echo "AKURAI_NOTES_MCP_TOKEN=$T" | tee -a "$F" >/dev/null
+    printf 'AKURAI_NOTES_MCP_TOKEN=%s\n' "$T" >> "$F"
+    chmod 0600 "$F"
     echo "  [generated] AKURAI_NOTES_MCP_TOKEN (value in $F)"
   fi
 
-  grep -q '^AKURAI_NOTES_MCP_OWNER_SUB=' "$F" || echo 'AKURAI_NOTES_MCP_OWNER_SUB=olibuijr@olibuijr.com' | tee -a "$F" >/dev/null
+  append_if_missing AKURAI_NOTES_MCP_OWNER_SUB olibuijr@olibuijr.com
+fi
+
+set_kv AKURAI_NOTES_EMBED_URL http://127.0.0.1:4219/v1/embeddings
+set_kv AKURAI_NOTES_EMBED_MODEL intfloat/multilingual-e5-small
+ROUTER_KEY="$(awk -F= '/^AKURAI_ROUTER_API_KEY=/{print substr($0, index($0, "=") + 1)}' /etc/akurai-router/router.env 2>/dev/null | head -n 1)"
+if [ -n "$ROUTER_KEY" ]; then
+  set_kv AKURAI_NOTES_EMBED_API_KEY "$ROUTER_KEY"
 fi
 ENV
 }
 
-ensure_embedding_model() {
-  say "Model: Qwen3-Embedding GGUF"
-ssh "$HOST" 'bash -s' <<'MODEL'
-set -euo pipefail
-if ! command -v llama-server >/dev/null 2>&1; then
-  echo "llama-server not installed; skipping embedding model fetch"
-  exit 0
-fi
-DIR=/opt/akurai-notes-embed
-MODEL=Qwen3-Embedding-0.6B-Q4_K_M.gguf
-URL=https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF/resolve/main/$MODEL
-[ -d "$DIR" ] || sudo mkdir -p "$DIR" && sudo chown -R ubuntu:ubuntu "$DIR"
-
-if [ ! -f "$DIR/$MODEL" ] || [ "$(stat -c%s "$DIR/$MODEL")" -lt 200000000 ]; then
-  echo "Fetching $MODEL from $URL ..."
-  curl -fL "$URL" -o "/tmp/$MODEL" && sudo mv "/tmp/$MODEL" "$DIR/$MODEL"
-fi
-sudo chown ubuntu:ubuntu "$DIR/$MODEL"
-MODEL
-}
-
 install_systemd_units() {
-  say "Systemd: 3 units (app, mcp, embed)"
+  say "Systemd: 2 units (app, mcp)"
   ssh "$HOST" 'sudo bash -s' <<'UNITS'
 set -euo pipefail
 
@@ -172,32 +188,12 @@ ReadWritePaths=/var/lib/akurai-notes
 WantedBy=multi-user.target
 EOF
 
-cat > /etc/systemd/system/akurai-notes-embed.service <<EOF
-[Unit]
-Description=AkurAI-Notes on-device embedding server (CPU GGUF, OpenAI-compat)
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=simple
-User=akurai
-Group=akurai
-ExecStart=/usr/local/bin/llama-server \
-  --model /opt/akurai-notes-embed/Qwen3-Embedding-0.6B-Q4_K_M.gguf \
-  --host 127.0.0.1 --port 8081 \
-  --embedding
-Restart=always
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
-EOF
-
 mkdir -p /opt/akurai-notes /var/lib/akurai-notes /etc/akurai-notes
 chown -R ubuntu:ubuntu /opt/akurai-notes /var/lib/akurai-notes
 
+systemctl disable --now akurai-notes-embed.service 2>/dev/null || true
+rm -f /etc/systemd/system/akurai-notes-embed.service
 systemctl daemon-reload
-systemctl enable --now akurai-notes-embed.service || echo "  (akurai-notes-embed.service not started -- install llama-server separately)"
 systemctl restart  akurai-notes.service
 systemctl restart  akurai-notes-mcp.service
 echo "  [done] systemd units installed + restarted"
diff --git a/site/backend/page.json b/site/backend/page.json
index e93c53c..6a08294 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -1,7 +1,7 @@
 {
   "title": "AkurAI-Notes — Private notes with semantic search",
   "description": "Private notes with semantic search — running on your own server, never scanned by a third party.",
-  "version": "v0.0.4",
+  "version": "v0.0.5",
   "url": "https://akurai-notes.olibuijr.com",
   "year": 2026,
   "brand_name": "AkurAI-Notes",