diff --git a/Cargo.lock b/Cargo.lock index aad2db6..9067597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -562,7 +562,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -650,7 +650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1002,9 +1002,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.13" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2247,7 +2247,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2305,7 +2305,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2588,7 +2588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2694,7 +2694,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3242,7 +3242,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/README.md b/README.md index 1f958d6..f878e41 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,16 @@ maintenance requests; it is not the formal agent interface. Config file: `~/.config/flicknote/config.json` Environment variables: + - `FLICKNOTE_SUPABASE_URL` - `FLICKNOTE_SUPABASE_KEY` - `FLICKNOTE_POWERSYNC_URL` +- `FLICKNOTE_API_URL` — API Worker base URL for share links +- `FLICKNOTE_GATEWAY_URL` — Gateway origin for attachment operations and `gateway request` + +`apiUrl` and `gatewayUrl` can also be set in `config.json`. After changing either +value, restart the daemon with `flicknote daemon restart`. Configure the two +endpoint values together; setting only one is rejected. Data directory: `~/.local/share/flicknote/` diff --git a/flicknote-cli/src/commands/daemon.rs b/flicknote-cli/src/commands/daemon.rs index 8f521e0..1e426e4 100644 --- a/flicknote-cli/src/commands/daemon.rs +++ b/flicknote-cli/src/commands/daemon.rs @@ -479,6 +479,7 @@ mod tests { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: ConfigPaths { config_dir: directory.to_path_buf(), diff --git a/flicknote-cli/src/commands/daemon_lifecycle.rs b/flicknote-cli/src/commands/daemon_lifecycle.rs index 7c7edb1..2153c88 100644 --- a/flicknote-cli/src/commands/daemon_lifecycle.rs +++ b/flicknote-cli/src/commands/daemon_lifecycle.rs @@ -410,6 +410,7 @@ mod tests { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: flicknote_core::config::ConfigPaths { config_dir: directory.path().to_path_buf(), diff --git a/flicknote-cli/src/commands/login.rs b/flicknote-cli/src/commands/login.rs index 7051f12..dcce67e 100644 --- a/flicknote-cli/src/commands/login.rs +++ b/flicknote-cli/src/commands/login.rs @@ -165,6 +165,7 @@ mod tests { supabase_anon_key: "key".to_string(), powersync_url: "http://127.0.0.1:9".to_string(), api_url: "http://127.0.0.1:9".to_string(), + gateway_url: "http://127.0.0.1:9".to_string(), web_url: None, paths: flicknote_core::config::ConfigPaths { config_dir: directory.to_path_buf(), diff --git a/flicknote-cli/src/commands/logout.rs b/flicknote-cli/src/commands/logout.rs index 90dcee8..ab38d3b 100644 --- a/flicknote-cli/src/commands/logout.rs +++ b/flicknote-cli/src/commands/logout.rs @@ -96,6 +96,7 @@ mod tests { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: flicknote_core::config::ConfigPaths { config_dir: directory.to_path_buf(), diff --git a/flicknote-cli/src/commands/service_manager.rs b/flicknote-cli/src/commands/service_manager.rs index f089979..9b0bdde 100644 --- a/flicknote-cli/src/commands/service_manager.rs +++ b/flicknote-cli/src/commands/service_manager.rs @@ -249,6 +249,7 @@ fn service_environment(config: &Config) -> Vec<(String, String)> { "FLICKNOTE_SUPABASE_KEY", "FLICKNOTE_POWERSYNC_URL", "FLICKNOTE_API_URL", + "FLICKNOTE_GATEWAY_URL", "FLICKNOTE_WEB_URL", ] { if let Ok(value) = std::env::var(name) { @@ -442,6 +443,7 @@ mod tests { supabase_anon_key: "key".to_string(), powersync_url: "https://sync.example".to_string(), api_url: "https://api.example".to_string(), + gateway_url: "https://gateway.example".to_string(), web_url: None, paths: ConfigPaths { config_dir: root.path().join("config/flicknote"), diff --git a/flicknote-cli/src/gateway.rs b/flicknote-cli/src/gateway.rs index 9cf6314..779c63a 100644 --- a/flicknote-cli/src/gateway.rs +++ b/flicknote-cli/src/gateway.rs @@ -70,6 +70,7 @@ impl std::error::Error for GatewayRequestError {} impl GatewayClient { pub(crate) fn new(config: &Config) -> Result { + config.validate_gateway()?; let http = Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) @@ -82,7 +83,7 @@ impl GatewayClient { &config.paths.session_file, ) .map_err(|_| CliError::Http("Failed to configure Gateway authentication".into()))?, - gateway_origin: gateway_origin(&config.api_url)?, + gateway_origin: gateway_origin(&config.gateway_url)?, http, }) } @@ -189,26 +190,26 @@ fn valid_retry_after(value: &str) -> Option { } #[cfg(test)] -fn gateway_url(api_url: &str, path: &str) -> Result { - let origin = gateway_origin(api_url)?; +fn gateway_url(gateway_url: &str, path: &str) -> Result { + let origin = gateway_origin(gateway_url)?; gateway_path_url(&origin, path) } -fn gateway_origin(api_url: &str) -> Result { - let api_url = Url::parse(api_url).map_err(|_| { - CliError::Other("Configured Gateway API URL is invalid; update apiUrl".into()) +fn gateway_origin(gateway_url: &str) -> Result { + let gateway_url = Url::parse(gateway_url).map_err(|_| { + CliError::Other("Configured Gateway URL is invalid; update gatewayUrl".into()) })?; - if !matches!(api_url.scheme(), "http" | "https") - || api_url.host_str().is_none() - || !api_url.username().is_empty() - || api_url.password().is_some() + if !matches!(gateway_url.scheme(), "http" | "https") + || gateway_url.host_str().is_none() + || !gateway_url.username().is_empty() + || gateway_url.password().is_some() { return Err(CliError::Other( - "Configured Gateway API URL must be an HTTP(S) origin without credentials".into(), + "Configured Gateway URL must be an HTTP(S) origin without credentials".into(), )); } - let mut origin = api_url; + let mut origin = gateway_url; origin.set_path("/"); origin.set_query(None); origin.set_fragment(None); @@ -240,12 +241,13 @@ mod tests { use std::net::TcpListener; use std::thread; - fn config(api_url: String, session_file: std::path::PathBuf) -> Config { + fn config(gateway_url: String, session_file: std::path::PathBuf) -> Config { Config { supabase_url: "https://auth.example.test".to_string(), supabase_anon_key: "anon-key".to_string(), powersync_url: String::new(), - api_url, + api_url: String::new(), + gateway_url, web_url: None, paths: ConfigPaths { config_dir: std::path::PathBuf::new(), @@ -304,10 +306,10 @@ mod tests { #[test] fn gateway_url_uses_only_the_configured_origin_and_rejects_external_paths() { - let api_url = "https://dev-gw.flicknote.app/api/v1"; + let configured_gateway_url = "https://dev-gw.flicknote.app"; assert_eq!( - gateway_url(api_url, "/web/v1/search?query=flicknote") + gateway_url(configured_gateway_url, "/web/v1/search?query=flicknote") .unwrap() .as_str(), "https://dev-gw.flicknote.app/web/v1/search?query=flicknote" @@ -318,7 +320,10 @@ mod tests { "//example.com/web/v1/search", "web/v1/search", ] { - assert!(gateway_url(api_url, path).is_err(), "accepted {path}"); + assert!( + gateway_url(configured_gateway_url, path).is_err(), + "accepted {path}" + ); } } diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs index 3d40ac2..e452ab1 100644 --- a/flicknote-cli/src/main_tests/mcp.rs +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -135,7 +135,8 @@ fn test_config(directory: &std::path::Path) -> Config { supabase_url: "https://auth.example.test".to_string(), supabase_anon_key: "anon-key".to_string(), powersync_url: String::new(), - api_url: "https://gateway.example.test/api/v1".to_string(), + api_url: String::new(), + gateway_url: "https://gateway.example.test".to_string(), web_url: Some("https://app.example".to_string()), paths: ConfigPaths { config_dir: directory.to_path_buf(), diff --git a/flicknote-cli/tests/mcp_stdio.rs b/flicknote-cli/tests/mcp_stdio.rs index 2d61fa7..0aecbdc 100644 --- a/flicknote-cli/tests/mcp_stdio.rs +++ b/flicknote-cli/tests/mcp_stdio.rs @@ -49,6 +49,7 @@ fn test_config(config_root: &std::path::Path, data_root: &std::path::Path) -> Co supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: ConfigPaths { config_file: config_dir.join("config.json"), @@ -437,7 +438,8 @@ fn gateway_request_writes_a_chunked_sse_response_to_stdout_without_exposing_its_ ]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", &origin) .output() .unwrap(); @@ -475,7 +477,8 @@ fn gateway_request_forwards_piped_request_body_without_rewriting_it() { ]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", &origin) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -519,7 +522,8 @@ fn gateway_request_rejects_invalid_piped_json_before_sending_it() { ]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", "http://127.0.0.1:9/api/v1") + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", "http://127.0.0.1:9") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -554,7 +558,8 @@ fn gateway_request_bypasses_system_proxies() { .args(["gateway", "request", "--path", "/healthz"]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", origin) .env("HTTP_PROXY", &proxy) .env("http_proxy", &proxy) .env_remove("HTTPS_PROXY") @@ -598,7 +603,8 @@ fn gateway_request_refreshes_sessions_without_using_system_proxies() { .args(["gateway", "request", "--path", "/healthz"]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", &origin) .env("FLICKNOTE_SUPABASE_URL", &origin) .env("HTTP_PROXY", &proxy) .env("http_proxy", &proxy) @@ -637,7 +643,8 @@ fn gateway_request_does_not_forward_session_refresh_to_redirect_target() { .args(["gateway", "request", "--path", "/healthz"]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", &origin) .env("FLICKNOTE_SUPABASE_URL", &origin) .output() .unwrap(); @@ -674,7 +681,8 @@ fn gateway_request_does_not_echo_an_upstream_error_body() { ]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", origin) .output() .unwrap(); @@ -701,7 +709,8 @@ fn gateway_request_reports_http_date_retry_after() { .args(["gateway", "request", "--path", "/healthz"]) .env("XDG_CONFIG_HOME", &config_root) .env("XDG_DATA_HOME", &data_root) - .env("FLICKNOTE_API_URL", format!("{origin}/api/v1")) + .env("FLICKNOTE_API_URL", "https://api.example.test/api/v1") + .env("FLICKNOTE_GATEWAY_URL", origin) .output() .unwrap(); diff --git a/flicknote-core/src/config.rs b/flicknote-core/src/config.rs index 0eaaf38..f03a31e 100644 --- a/flicknote-core/src/config.rs +++ b/flicknote-core/src/config.rs @@ -1,5 +1,25 @@ +use serde::Deserialize; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ConfigFileValues { + supabase_url: String, + supabase_anon_key: String, + powersync_url: String, + api_url: String, + gateway_url: String, + web_url: Option, +} + +struct EndpointDefaults { + supabase_url: &'static str, + supabase_anon_key: &'static str, + powersync_url: &'static str, + api_url: &'static str, + gateway_url: &'static str, +} #[derive(Clone)] pub struct Config { @@ -7,6 +27,7 @@ pub struct Config { pub supabase_anon_key: String, pub powersync_url: String, pub api_url: String, + pub gateway_url: String, pub web_url: Option, pub paths: ConfigPaths, } @@ -49,32 +70,14 @@ impl Config { let db_file = data_dir.join("flicknote.db"); let log_file = data_dir.join("flicknote.log"); - let mut supabase_url = String::new(); - let mut supabase_anon_key = String::new(); - let mut powersync_url = String::new(); - let mut api_url = String::new(); - let mut web_url: Option = None; - - if config_file.exists() - && let Ok(raw) = fs::read_to_string(&config_file) - && let Ok(json) = serde_json::from_str::(&raw) - { - if let Some(v) = json.get("supabaseUrl").and_then(|v| v.as_str()) { - supabase_url = v.to_string(); - } - if let Some(v) = json.get("supabaseAnonKey").and_then(|v| v.as_str()) { - supabase_anon_key = v.to_string(); - } - if let Some(v) = json.get("powersyncUrl").and_then(|v| v.as_str()) { - powersync_url = v.to_string(); - } - if let Some(v) = json.get("apiUrl").and_then(|v| v.as_str()) { - api_url = v.to_string(); - } - if let Some(v) = json.get("webUrl").and_then(|v| v.as_str()) { - web_url = Some(v.to_string()); - } - } + let ConfigFileValues { + mut supabase_url, + mut supabase_anon_key, + mut powersync_url, + mut api_url, + mut gateway_url, + mut web_url, + } = read_config_file(&config_file); if let Ok(v) = std::env::var("FLICKNOTE_SUPABASE_URL") { supabase_url = v; @@ -88,10 +91,19 @@ impl Config { if let Ok(v) = std::env::var("FLICKNOTE_API_URL") { api_url = v; } + if let Ok(v) = std::env::var("FLICKNOTE_GATEWAY_URL") { + gateway_url = v; + } if let Ok(v) = std::env::var("FLICKNOTE_WEB_URL") { web_url = Some(v); } + if api_url.is_empty() != gateway_url.is_empty() { + return Err(crate::error::CliError::Other( + "apiUrl and gatewayUrl must be configured together — set both in config.json or via FLICKNOTE_API_URL and FLICKNOTE_GATEWAY_URL".into(), + )); + } + // Fallback: per-field built-in defaults if nothing else configured that field. // Each field is guarded independently so a user can override just one env var // (e.g. FLICKNOTE_POWERSYNC_URL) without losing their custom value when @@ -100,20 +112,24 @@ impl Config { || supabase_anon_key.is_empty() || powersync_url.is_empty() || api_url.is_empty() + || gateway_url.is_empty() { let env = std::env::var("FLICKNOTE_ENV").unwrap_or_else(|_| "dev".into()); - let (s_url, s_key, ps_url, a_url) = builtin_defaults(&env); + let defaults = builtin_defaults(&env); if supabase_url.is_empty() { - supabase_url = s_url.into(); + supabase_url = defaults.supabase_url.into(); } if supabase_anon_key.is_empty() { - supabase_anon_key = s_key.into(); + supabase_anon_key = defaults.supabase_anon_key.into(); } if powersync_url.is_empty() { - powersync_url = ps_url.into(); + powersync_url = defaults.powersync_url.into(); } if api_url.is_empty() { - api_url = a_url.into(); + api_url = defaults.api_url.into(); + } + if gateway_url.is_empty() { + gateway_url = defaults.gateway_url.into(); } } @@ -131,6 +147,7 @@ impl Config { supabase_anon_key, powersync_url, api_url, + gateway_url, web_url, paths, }) @@ -146,6 +163,17 @@ impl Config { Ok(()) } + /// Validate that gateway_url is set. Call before Gateway operations. + pub fn validate_gateway(&self) -> Result<(), crate::error::CliError> { + if self.gateway_url.is_empty() { + return Err(crate::error::CliError::Other( + "gatewayUrl is not configured — set it in config.json or FLICKNOTE_GATEWAY_URL" + .into(), + )); + } + Ok(()) + } + /// Validate that required fields are set. Call before operations that need them. /// Under normal usage built-in defaults fill these fields, but explicit empty-string /// env vars (e.g. `FLICKNOTE_SUPABASE_URL=`) or a broken config.json can still @@ -172,20 +200,36 @@ impl Config { } } -fn builtin_defaults(env: &str) -> (&'static str, &'static str, &'static str, &'static str) { - match env { +fn read_config_file(config_file: &Path) -> ConfigFileValues { + fs::read_to_string(config_file) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() +} + +fn builtin_defaults(env: &str) -> EndpointDefaults { + let (supabase_url, supabase_anon_key, powersync_url, api_url, gateway_url) = match env { "prod" => ( "https://auth.flicknote.app", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFocGNqYW1maGJpb3BqZG5laW5uIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTA0NTc1NDIsImV4cCI6MjA2NjAzMzU0Mn0.g6B2UohS8Zw_mrsDljAB7n6feUTvpmMVvvsf7VMRXA4", "https://sync.flicknote.app", - "https://gw.flicknote.app/api/v1", + "https://api.flicknote.app/api/v1", + "https://gw.flicknote.app", ), _ => ( "https://dev-auth.flicknote.app", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY1NTM1NTg4LCJleHAiOjE5MjMyMTU1ODh9.7ErMPvghlVm6mew-IKjSShP1Lf6wTCbNgs9ufuh3yqo", "https://dev-sync.flicknote.app", - "https://dev-gw.flicknote.app/api/v1", + "https://dev-api.flicknote.app/api/v1", + "https://dev-gw.flicknote.app", ), + }; + EndpointDefaults { + supabase_url, + supabase_anon_key, + powersync_url, + api_url, + gateway_url, } } @@ -206,7 +250,10 @@ mod tests { "FLICKNOTE_SUPABASE_KEY", "FLICKNOTE_POWERSYNC_URL", "FLICKNOTE_API_URL", + "FLICKNOTE_GATEWAY_URL", "FLICKNOTE_WEB_URL", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", ]; let saved: Vec<_> = keys.iter().map(|k| std::env::var(k).ok()).collect(); @@ -229,26 +276,28 @@ mod tests { #[test] fn test_builtin_defaults_dev() { - let (url, key, ps, api) = builtin_defaults("dev"); - assert_eq!(url, "https://dev-auth.flicknote.app"); - assert_eq!(ps, "https://dev-sync.flicknote.app"); - assert_eq!(api, "https://dev-gw.flicknote.app/api/v1"); - assert!(!key.is_empty()); + let defaults = builtin_defaults("dev"); + assert_eq!(defaults.supabase_url, "https://dev-auth.flicknote.app"); + assert_eq!(defaults.powersync_url, "https://dev-sync.flicknote.app"); + assert_eq!(defaults.api_url, "https://dev-api.flicknote.app/api/v1"); + assert_eq!(defaults.gateway_url, "https://dev-gw.flicknote.app"); + assert!(!defaults.supabase_anon_key.is_empty()); } #[test] fn test_builtin_defaults_prod() { - let (url, key, ps, api) = builtin_defaults("prod"); - assert_eq!(url, "https://auth.flicknote.app"); - assert_eq!(ps, "https://sync.flicknote.app"); - assert_eq!(api, "https://gw.flicknote.app/api/v1"); - assert!(!key.is_empty()); + let defaults = builtin_defaults("prod"); + assert_eq!(defaults.supabase_url, "https://auth.flicknote.app"); + assert_eq!(defaults.powersync_url, "https://sync.flicknote.app"); + assert_eq!(defaults.api_url, "https://api.flicknote.app/api/v1"); + assert_eq!(defaults.gateway_url, "https://gw.flicknote.app"); + assert!(!defaults.supabase_anon_key.is_empty()); } #[test] fn test_builtin_defaults_unknown_falls_back_to_dev() { - let (url, _, _, _) = builtin_defaults("staging"); - assert_eq!(url, "https://dev-auth.flicknote.app"); + let defaults = builtin_defaults("staging"); + assert_eq!(defaults.supabase_url, "https://dev-auth.flicknote.app"); } #[test] @@ -287,7 +336,7 @@ mod tests { let cfg_file = cfg_dir.join("config.json"); std::fs::write( &cfg_file, - r#"{"supabaseUrl":"https://file.example.com","supabaseAnonKey":"key","powersyncUrl":"https://ps.example.com","apiUrl":"https://api.example.com/v1"}"#, + r#"{"supabaseUrl":"https://file.example.com","supabaseAnonKey":"key","powersyncUrl":"https://ps.example.com","apiUrl":"https://api.example.com/v1","gatewayUrl":"https://gateway.example.com"}"#, ) .unwrap(); unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) }; @@ -302,6 +351,30 @@ mod tests { }; let cfg = Config::load().expect("Config::load should succeed"); assert_eq!(cfg.supabase_url, "https://file.example.com"); + assert_eq!(cfg.api_url, "https://api.example.com/v1"); + assert_eq!(cfg.gateway_url, "https://gateway.example.com"); + }); + } + + #[test] + fn test_config_file_rejects_an_unpaired_endpoint_url() { + with_clean_env(None, || { + let tmp = tempfile::tempdir().expect("tempdir"); + let cfg_dir = tmp.path().join("flicknote"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + std::fs::write( + cfg_dir.join("config.json"), + r#"{"apiUrl":"https://api.example.com/v1"}"#, + ) + .unwrap(); + unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) }; + unsafe { std::env::set_var("XDG_DATA_HOME", tmp.path().join("data")) }; + + let error = match Config::load() { + Ok(_) => panic!("unpaired endpoints must be rejected"), + Err(error) => error, + }; + assert!(error.to_string().contains("must be configured together")); }); } } diff --git a/flicknote-sync/src/ipc/tests.rs b/flicknote-sync/src/ipc/tests.rs index 2dcb4ac..c17c06a 100644 --- a/flicknote-sync/src/ipc/tests.rs +++ b/flicknote-sync/src/ipc/tests.rs @@ -9,6 +9,7 @@ fn test_config(directory: &std::path::Path) -> Config { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: ConfigPaths { config_dir: directory.to_path_buf(), @@ -49,6 +50,7 @@ fn socket_path_lives_in_data_dir() { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: ConfigPaths { config_dir: dir.clone(), diff --git a/flicknote-sync/src/remote/attachment.rs b/flicknote-sync/src/remote/attachment.rs index 715fbf6..4281332 100644 --- a/flicknote-sync/src/remote/attachment.rs +++ b/flicknote-sync/src/remote/attachment.rs @@ -21,11 +21,12 @@ struct UploadUrlResponse { content_type: String, } -pub(super) fn validate_api_url(config: &Config) -> Result<(), DaemonError> { - if config.api_url.is_empty() { +pub(super) fn validate_gateway_url(config: &Config) -> Result<(), DaemonError> { + if config.gateway_url.is_empty() { return Err(DaemonError::Other { - message: "apiUrl is not configured — set it in config.json or FLICKNOTE_API_URL" - .to_string(), + message: + "gatewayUrl is not configured — set it in config.json or FLICKNOTE_GATEWAY_URL" + .to_string(), }); } Ok(()) @@ -38,7 +39,7 @@ pub(super) async fn upload_attachment( note_id: &str, file_path: &Path, ) -> Result<(), DaemonError> { - validate_api_url(config)?; + validate_gateway_url(config)?; let filename = file_path .file_name() .and_then(|n| n.to_str()) @@ -48,7 +49,7 @@ pub(super) async fn upload_attachment( .to_string(); let resp = http - .post(attachment_endpoint(&config.api_url, "upload-url")) + .post(attachment_endpoint(&config.gateway_url, "upload-url")) .bearer_auth(access_token) .json(&serde_json::json!({ "noteId": note_id, "filename": filename })) .send() @@ -99,9 +100,9 @@ pub(super) async fn delete_attachment( access_token: &str, note_id: &str, ) -> Result<(), DaemonError> { - validate_api_url(config)?; + validate_gateway_url(config)?; let resp = http - .delete(attachment_endpoint(&config.api_url, note_id)) + .delete(attachment_endpoint(&config.gateway_url, note_id)) .bearer_auth(access_token) .send() .await @@ -118,3 +119,34 @@ pub(super) async fn delete_attachment( message: format!("Delete failed: {body}"), }) } + +#[cfg(test)] +mod tests { + use super::*; + use flicknote_core::config::{Config, ConfigPaths}; + + #[test] + fn attachment_requests_use_the_gateway_url() { + let config = Config { + supabase_url: String::new(), + supabase_anon_key: String::new(), + powersync_url: String::new(), + api_url: "https://api.example.test/api/v1".to_string(), + gateway_url: "https://gateway.example.test".to_string(), + web_url: None, + paths: ConfigPaths { + config_dir: Default::default(), + data_dir: Default::default(), + config_file: Default::default(), + session_file: Default::default(), + db_file: Default::default(), + log_file: Default::default(), + }, + }; + + assert_eq!( + attachment_endpoint(&config.gateway_url, "upload-url"), + "https://gateway.example.test/api/v1/attachments/upload-url" + ); + } +} diff --git a/flicknote-sync/src/remote/create/tests.rs b/flicknote-sync/src/remote/create/tests.rs index 95e74b8..9407f41 100644 --- a/flicknote-sync/src/remote/create/tests.rs +++ b/flicknote-sync/src/remote/create/tests.rs @@ -146,7 +146,7 @@ fn partial_remote_create_maps_to_non_retryable_structured_service_error() { async fn remote_create_returns_after_canonical_note_is_committed_locally() { let body = r#"[{"id":"note-create","short_id":77,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_server(vec![("201 Created", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -201,7 +201,7 @@ async fn remote_create_reports_typed_partial_success_after_note_commit() { r#"{"message":"topic failure"}"#, ), ]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -256,7 +256,7 @@ async fn remote_create_reports_typed_partial_success_after_note_commit() { async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { let body = r#"[{"id":"note-retry","short_id":78,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -298,7 +298,7 @@ async fn remote_create_recovers_empty_idempotent_response_by_stable_uuid() { async fn remote_create_recovers_malformed_success_response_by_stable_uuid() { let body = r#"[{"id":"note-malformed","short_id":81,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_server(vec![("201 Created", "{"), ("200 OK", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -341,7 +341,7 @@ async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() ("201 Created", "{"), ("503 Service Unavailable", r#"{"message":"try later"}"#), ]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -385,7 +385,7 @@ async fn malformed_success_with_failed_reconciliation_reports_confirmed_create() async fn local_commit_failure_after_remote_create_reports_partial_success() { let note = r#"[{"id":"note-local-failure","short_id":82,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Remote title","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_server(vec![("201 Created", note)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -434,7 +434,7 @@ async fn local_commit_failure_after_remote_create_reports_partial_success() { async fn remote_create_recovers_lost_response_by_stable_uuid() { let body = r#"[{"id":"note-lost","short_id":79,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_disconnected_response_then_server("200 OK", body); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -472,7 +472,7 @@ async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { "503 Service Unavailable", r#"{"message":"try later"}"#, ); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -516,7 +516,7 @@ async fn ambiguous_transport_failure_reports_stable_unknown_outcome() { async fn ambiguous_transport_failure_retries_create_with_the_same_stable_uuid() { let body = r#"[{"id":"note-recovered-after-retry","short_id":83,"user_id":"user-1","type":"normal","status":"ai_queued","title":"Recovered","content":"Body","summary":null,"is_flagged":false,"project_id":null,"metadata":null,"source":null,"created_at":"2026-08-09T00:00:00Z","updated_at":"2026-08-09T00:00:00Z","deleted_at":null}]"#; let (origin, server) = spawn_disconnected_then_retry_responses(vec![("201 Created", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -557,7 +557,7 @@ async fn retryable_status_retries_create_with_the_same_stable_uuid() { ("503 Service Unavailable", r#"{"message":"try later"}"#), ("201 Created", body), ]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -598,7 +598,7 @@ async fn retryable_status_retries_create_with_the_same_stable_uuid() { async fn remote_extraction_create_commits_confirmed_rows_locally() { let body = r#"[{"id":"extraction-create","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; let (origin, server) = spawn_server(vec![("201 Created", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -640,7 +640,7 @@ async fn remote_extraction_create_commits_confirmed_rows_locally() { async fn remote_extraction_create_recovers_by_stable_uuid() { let body = r#"[{"id":"extraction-retry","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; let (origin, server) = spawn_server(vec![("200 OK", "[]"), ("200 OK", body)]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; @@ -676,7 +676,7 @@ async fn remote_extraction_create_recovers_by_stable_uuid() { async fn remote_extraction_create_commits_confirmed_subset_and_reports_exact_pending_ids() { let body = r#"[{"id":"extraction-confirmed","note_id":"note-create","user_id":"user-1","key":"::topic","value":"rust"}]"#; let (origin, server) = spawn_server(vec![("201 Created", body), ("200 OK", "[]")]); - let mut config = test_config(String::new()); + let mut config = empty_test_config(); config.supabase_url = origin; config.supabase_anon_key = "anon-key".to_string(); let (_directory, db) = test_powersync_db().await; diff --git a/flicknote-sync/src/remote/share.rs b/flicknote-sync/src/remote/share.rs index 2dea477..6356640 100644 --- a/flicknote-sync/src/remote/share.rs +++ b/flicknote-sync/src/remote/share.rs @@ -10,7 +10,6 @@ use flicknote_core::{ use serde::Deserialize; use crate::ipc::DaemonError; -use crate::remote::attachment::validate_api_url; #[cfg(test)] mod tests; @@ -110,7 +109,9 @@ async fn get_or_create_share_with_token( access_token: &str, request: &ShareRequest, ) -> Result { - validate_api_url(config)?; + config.validate_api().map_err(|error| DaemonError::Other { + message: error.to_string(), + })?; let endpoint = share_endpoint(&config.api_url, request); let response = http .get(&endpoint) @@ -154,7 +155,9 @@ async fn revoke_share_with_token( access_token: &str, request: &ShareRequest, ) -> Result<(), DaemonError> { - validate_api_url(config)?; + config.validate_api().map_err(|error| DaemonError::Other { + message: error.to_string(), + })?; let response = http .delete(share_endpoint(&config.api_url, request)) .bearer_auth(access_token) diff --git a/flicknote-sync/src/remote/share/tests.rs b/flicknote-sync/src/remote/share/tests.rs index 97247b1..72dc381 100644 --- a/flicknote-sync/src/remote/share/tests.rs +++ b/flicknote-sync/src/remote/share/tests.rs @@ -35,7 +35,10 @@ async fn returns_existing_note_share_without_replacing_it() { "200 OK", r#"{"token":"existing","url":"https://flicknote.app/s/existing"}"#, )]); - let config = test_config(format!("{api_origin}/api/v1")); + let config = test_config( + format!("{api_origin}/api/v1"), + "http://127.0.0.1:1".to_string(), + ); let request = ShareRequest { resource: ShareResource::Note, id: "550e8400-e29b-41d4-a716-446655440000".to_string(), @@ -65,7 +68,7 @@ async fn creates_project_share_when_none_exists() { r#"{"token":"new-token","url":"https://flicknote.app/p/new-token"}"#, ), ]); - let config = test_config(api_url); + let config = test_config(api_url, "http://127.0.0.1:1".to_string()); let request = ShareRequest { resource: ShareResource::Project, id: "550e8400-e29b-41d4-a716-446655440000".to_string(), @@ -89,7 +92,7 @@ async fn creates_project_share_when_none_exists() { #[tokio::test] async fn revokes_existing_note_share() { let (api_url, server) = spawn_server(vec![("200 OK", r#"{"success":true}"#)]); - let config = test_config(api_url); + let config = test_config(api_url, "http://127.0.0.1:1".to_string()); let request = ShareRequest { resource: ShareResource::Note, id: "550e8400-e29b-41d4-a716-446655440000".to_string(), diff --git a/flicknote-sync/src/test_support.rs b/flicknote-sync/src/test_support.rs index 33618cd..b3499fd 100644 --- a/flicknote-sync/src/test_support.rs +++ b/flicknote-sync/src/test_support.rs @@ -62,12 +62,13 @@ pub(crate) async fn insert_marked_note(db: &PowerSyncDatabase) { insert_note_with_metadata(db, REMOTE_COMMITTED_INSERT_METADATA).await; } -pub(crate) fn test_config(api_url: String) -> Config { +pub(crate) fn test_config(api_url: String, gateway_url: String) -> Config { Config { supabase_url: String::new(), supabase_anon_key: String::new(), powersync_url: String::new(), api_url, + gateway_url, web_url: None, paths: ConfigPaths { config_dir: PathBuf::new(), @@ -80,6 +81,10 @@ pub(crate) fn test_config(api_url: String) -> Config { } } +pub(crate) fn empty_test_config() -> Config { + test_config(String::new(), String::new()) +} + pub(crate) fn spawn_server( responses: Vec<(&'static str, &'static str)>, ) -> (String, thread::JoinHandle>) { diff --git a/flicknote-sync/tests/app_contract.rs b/flicknote-sync/tests/app_contract.rs index 88716e9..47d7ba4 100644 --- a/flicknote-sync/tests/app_contract.rs +++ b/flicknote-sync/tests/app_contract.rs @@ -22,6 +22,7 @@ fn test_config(directory: &std::path::Path) -> Config { supabase_anon_key: String::new(), powersync_url: String::new(), api_url: String::new(), + gateway_url: String::new(), web_url: None, paths: ConfigPaths { config_dir: directory.to_path_buf(),