Menu
Commit 705e1ce0
705e1ce035ea4b9fb4c413dd38f550650185b571 by Claude Code
- Cargo.lock +4 -4
- Cargo.toml +1 -1
- VERSION +1 -1
- crates/notes-api/src/embed.rs +15 -2
- crates/notes-api/src/handlers/crud.rs +2 -0
- crates/notes-api/src/handlers/search.rs +4 -3
- crates/notes-api/src/router_setup.rs +10 -2
- crates/notes-api/tests/embed_test.rs +195 -3
- crates/notes-cli/src/cmd_mcp.rs +6 -0
- crates/notes-cli/src/main.rs +100 -0
- crates/notes-mcp/src/server.rs +2 -0
- crates/notes-mcp/src/tools.rs +5 -2
- crates/notes-mcp/tests/mcp_test.rs +104 -1
- deploy.sh +53 -57
- site/backend/page.json +1 -1
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: ¬es_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(
+ ®istry,
+ "Deploy runbook",
+ "akurai-ec2 ports drift before release",
+ "testuser@example.com",
+ );
+ store_embedding(®istry, 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",