Skip to main content

max / balanced_breakfast

TagTree + DocEngine integration, API key setup UI, ownership optimizations - ammonia replaced with docengine::sanitize_html - Tag validation via tagtree::validate_with (depth 3, length 80) - Sync settings: API key test/save UI replaces OAuth-only connect - sync_client changed to RwLock<Option<Arc<SyncKitClient>>> - feed_item_to_summary takes ownership (moves instead of clones) - Orchestrator RwLock held shorter across async boundary - crypto key functions narrowed to private visibility
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:30 UTC
Commit: 311818fe624001e1b946045ee1623c846475a9bb
Parent: bb246f2
15 files changed, +320 insertions, -148 deletions
M Cargo.lock +56 -2
@@ -198,6 +198,7 @@
198 198 "sha2",
199 199 "sqlx",
200 200 "synckit-client",
201 + "tagtree",
201 202 "tauri",
202 203 "tauri-build",
203 204 "tauri-plugin-dialog",
@@ -234,12 +235,12 @@
234 235 version = "0.3.0"
235 236 dependencies = [
236 237 "aes-gcm",
237 - "ammonia",
238 238 "base64 0.22.1",
239 239 "bb-db",
240 240 "bb-feed",
241 241 "bb-interface",
242 242 "chrono",
243 + "docengine",
243 244 "html2text",
244 245 "keyring",
245 246 "parking_lot",
@@ -971,6 +972,15 @@
971 972 "syn 2.0.114",
972 973 ]
973 974
975 + [[package]]
976 + name = "docengine"
977 + version = "0.3.0"
978 + dependencies = [
979 + "ammonia",
980 + "pulldown-cmark",
981 + "serde",
982 + ]
983 +
974 984 [[package]]
975 985 name = "dotenvy"
976 986 version = "0.15.7"
@@ -1438,6 +1448,15 @@
1438 1448 "version_check",
1439 1449 ]
1440 1450
1451 + [[package]]
1452 + name = "getopts"
1453 + version = "0.2.24"
1454 + source = "registry+https://github.com/rust-lang/crates.io-index"
1455 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
1456 + dependencies = [
1457 + "unicode-width 0.2.2",
1458 + ]
1459 +
1441 1460 [[package]]
1442 1461 name = "getrandom"
1443 1462 version = "0.1.16"
@@ -1736,7 +1755,7 @@
1736 1755 "markup5ever 0.12.1",
1737 1756 "tendril",
1738 1757 "thiserror 1.0.69",
1739 - "unicode-width",
1758 + "unicode-width 0.1.13",
1740 1759 ]
1741 1760
1742 1761 [[package]]
@@ -3449,6 +3468,25 @@
3449 3468 "unicode-ident",
3450 3469 ]
3451 3470
3471 + [[package]]
3472 + name = "pulldown-cmark"
3473 + version = "0.12.2"
3474 + source = "registry+https://github.com/rust-lang/crates.io-index"
3475 + checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
3476 + dependencies = [
3477 + "bitflags 2.10.0",
3478 + "getopts",
3479 + "memchr",
3480 + "pulldown-cmark-escape",
3481 + "unicase",
3482 + ]
3483 +
3484 + [[package]]
3485 + name = "pulldown-cmark-escape"
3486 + version = "0.11.0"
3487 + source = "registry+https://github.com/rust-lang/crates.io-index"
3488 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
3489 +
3452 3490 [[package]]
3453 3491 name = "quick-xml"
3454 3492 version = "0.38.4"
@@ -4843,6 +4881,10 @@
4843 4881 "version-compare",
4844 4882 ]
4845 4883
4884 + [[package]]
4885 + name = "tagtree"
4886 + version = "0.3.0"
4887 +
4846 4888 [[package]]
4847 4889 name = "tao"
4848 4890 version = "0.34.5"
@@ -5759,6 +5801,12 @@
5759 5801 "unic-common",
5760 5802 ]
5761 5803
5804 + [[package]]
5805 + name = "unicase"
5806 + version = "2.9.0"
5807 + source = "registry+https://github.com/rust-lang/crates.io-index"
5808 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
5809 +
5762 5810 [[package]]
5763 5811 name = "unicode-bidi"
5764 5812 version = "0.3.18"
@@ -5798,6 +5846,12 @@
5798 5846 source = "registry+https://github.com/rust-lang/crates.io-index"
5799 5847 checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
5800 5848
5849 + [[package]]
5850 + name = "unicode-width"
5851 + version = "0.2.2"
5852 + source = "registry+https://github.com/rust-lang/crates.io-index"
5853 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
5854 +
5801 5855 [[package]]
5802 5856 name = "universal-hash"
5803 5857 version = "0.5.1"
M Cargo.toml +2
@@ -53,4 +53,6 @@
53 53 bb-feed = { path = "crates/bb-feed" }
54 54 bb-db = { path = "crates/bb-db" }
55 55 synckit-client = { path = "../synckit-client" }
56 + tauri = "2.10.2"
56 57 tauri-plugin-updater = "2"
58 + tagtree = { path = "../tagtree" }
@@ -58,6 +58,9 @@
58 58 tracing.workspace = true
59 59 tracing-subscriber.workspace = true
60 60
61 + # Tag standard
62 + tagtree.workspace = true
63 +
61 64 # Desktop-only plugins
62 65 [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
63 66 tauri-plugin-shell = "2.3.5"
@@ -46,7 +46,7 @@
46 46 regex = "1"
47 47
48 48 # HTML sanitization for untrusted feed content
49 - ammonia = "4"
49 + docengine = { path = "../../../docengine" }
50 50
51 51 # Article extraction (reader view)
52 52 readable-readability = "0.4"
@@ -87,6 +87,8 @@
87 87 commands::import_opml,
88 88 commands::list_themes,
89 89 commands::get_theme,
90 + commands::sync_test_api_key,
91 + commands::sync_save_api_key,
90 92 commands::sync_status,
91 93 commands::sync_start_auth,
92 94 commands::sync_complete_auth,
@@ -1,6 +1,7 @@
1 1 //! Application state wrapping the Orchestrator
2 2 use bb_core::{Orchestrator, OrchestratorConfig};
3 3 use bb_db::parse_timestamp;
4 + use parking_lot::RwLock;
4 5 use std::path::PathBuf;
5 6 use std::sync::Arc;
6 7 use synckit_client::{SyncKitClient, SyncKitConfig};
@@ -8,11 +9,16 @@
8 9 use tokio::task::AbortHandle;
9 10 use tracing::{debug, error, info, warn};
10 11
12 + /// Default sync server URL.
13 + pub const SYNC_SERVER_URL: &str = "https://makenot.work";
14 +
11 15 /// Application state wrapping the Orchestrator
12 16 pub struct AppState {
13 17 pub orchestrator: Orchestrator,
14 - /// SyncKit client for cloud sync (None if not configured).
15 - pub sync_client: Option<SyncKitClient>,
18 + /// SyncKit client for cloud sync (None until configured via API key).
19 + pub sync_client: RwLock<Option<Arc<SyncKitClient>>>,
20 + /// App data directory for key persistence.
21 + pub data_dir: PathBuf,
16 22 /// Handle to abort the background auto-fetch task on shutdown.
17 23 auto_fetch_handle: parking_lot::Mutex<Option<AbortHandle>>,
18 24 /// Handle to abort the background stale-item cleanup task on shutdown.
@@ -87,35 +93,15 @@
87 93 // Initialize plugins from DB
88 94 init_plugins_from_db(&orchestrator).await;
89 95
90 - // Initialize SyncKit client from env vars (optional)
91 - let sync_client = match (
92 - std::env::var("BB_SYNC_SERVER_URL"),
93 - std::env::var("BB_SYNC_API_KEY"),
94 - ) {
95 - (Ok(server_url), Ok(api_key)) => {
96 - info!(%server_url, "SyncKit client configured");
97 - let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
98 -
99 - // Try to restore session from keychain
100 - match client.try_load_key_from_keychain() {
101 - Ok(true) => info!("Sync encryption key loaded from keychain"),
102 - Ok(false) => debug!("No sync encryption key in keychain"),
103 - Err(e) => warn!(error = %e, "Failed to load sync encryption key"),
104 - }
105 -
106 - Some(client)
107 - }
108 - _ => {
109 - debug!("SyncKit not configured (BB_SYNC_SERVER_URL / BB_SYNC_API_KEY not set)");
110 - None
111 - }
112 - };
96 + // Initialize SyncKit client from saved key or env vars
97 + let sync_client = load_sync_client(&app_data_dir);
113 98
114 99 info!("Application state initialized");
115 100
116 101 Ok(Self {
117 102 orchestrator,
118 - sync_client,
103 + sync_client: RwLock::new(sync_client.map(Arc::new)),
104 + data_dir: app_data_dir,
119 105 auto_fetch_handle: parking_lot::Mutex::new(None),
120 106 cleanup_handle: parking_lot::Mutex::new(None),
121 107 })
@@ -166,6 +152,39 @@
166 152 }
167 153 }
168 154
155 + /// Load API key from the saved file, falling back to env var.
156 + pub fn load_api_key(data_dir: &std::path::Path) -> Option<String> {
157 + let key_path = data_dir.join("sync_api_key");
158 + if let Ok(key) = std::fs::read_to_string(&key_path) {
159 + let key = key.trim().to_string();
160 + if !key.is_empty() {
161 + return Some(key);
162 + }
163 + }
164 + std::env::var("BB_SYNC_API_KEY").ok()
165 + }
166 +
167 + /// Save API key to the data directory.
168 + pub fn save_api_key(data_dir: &std::path::Path, api_key: &str) {
169 + let key_path = data_dir.join("sync_api_key");
170 + let _ = std::fs::write(&key_path, api_key);
171 + }
172 +
173 + /// Create a SyncKitClient from the saved or env-var API key.
174 + fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> {
175 + let api_key = load_api_key(data_dir)?;
176 + let server_url = std::env::var("BB_SYNC_SERVER_URL")
177 + .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
178 + info!(%server_url, "SyncKit client configured");
179 + let client = SyncKitClient::new(SyncKitConfig { server_url, api_key });
180 + match client.try_load_key_from_keychain() {
181 + Ok(true) => info!("Sync encryption key loaded from keychain"),
182 + Ok(false) => debug!("No sync encryption key in keychain"),
183 + Err(e) => warn!(error = %e, "Failed to load sync encryption key"),
184 + }
185 + Some(client)
186 + }
187 +
169 188 /// Find the plugins directory, preferring dev-mode project root
170 189 fn find_plugins_dir(app: &AppHandle) -> PathBuf {
171 190 // In dev mode, use the project-root plugins/ directory
@@ -34,8 +34,7 @@
34 34 warn!(error = %e, "Sync changelog retention check failed");
35 35 }
36 36
37 - let sync_client = state.sync_client.as_ref();
38 - let client = match sync_client {
37 + let client: Arc<synckit_client::SyncKitClient> = match state.sync_client.read().clone() {
39 38 Some(c) => c,
40 39 None => continue,
41 40 };
@@ -107,7 +106,7 @@
107 106 }
108 107
109 108 // Perform sync
110 - match sync_service::perform_sync(pool, client).await {
109 + match sync_service::perform_sync(pool, &client).await {
111 110 Ok(result) => {
112 111 consecutive_failures = 0;
113 112 backoff_until = None;
@@ -18,14 +18,14 @@
18 18 const PREFIX: &str = "bb_enc:v1:";
19 19
20 20 /// Generate a random 256-bit encryption key.
21 - pub fn generate_key() -> [u8; 32] {
21 + fn generate_key() -> [u8; 32] {
22 22 let mut key = [0u8; 32];
23 23 rand::thread_rng().fill_bytes(&mut key);
24 24 key
25 25 }
26 26
27 27 /// Load an encryption key from a file, or generate and save one if it doesn't exist.
28 - pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> {
28 + fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> {
29 29 if path.exists() {
30 30 let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?;
31 31 if data.len() != 32 {
@@ -189,8 +189,15 @@
189 189 return Ok(0);
190 190 };
191 191
192 - let plugins = self.plugins.read().await;
193 - let result = match plugins.fetch(plugin_id, None) {
192 + // Acquire the read lock only for the fetch call, then release it
193 + // before any async DB operations to avoid holding the lock across
194 + // blocking network I/O.
195 + let fetch_result = {
196 + let plugins = self.plugins.read().await;
197 + plugins.fetch(plugin_id, None)
198 + };
199 +
200 + let result = match fetch_result {
194 201 Ok(r) => r,
195 202 Err(e) => {
196 203 // Record fetch failure before propagating
@@ -233,7 +240,7 @@
233 240 // Sanitize HTML in body to strip scripts, event handlers, and
234 241 // other dangerous markup from untrusted feed/plugin content.
235 242 if let Some(ref body) = create_item.body {
236 - create_item.body = Some(ammonia::clean(body));
243 + create_item.body = Some(docengine::sanitize_html(body));
237 244 }
238 245
239 246 match self.db.items().upsert(create_item).await {
@@ -95,6 +95,10 @@
95 95
96 96 // --- Sync: cloud sync via MNW SyncKit ---
97 97 sync: {
98 + /** Validate an API key against the server. Returns app name. */
99 + testApiKey: (apiKey) => invoke('sync_test_api_key', { apiKey }),
100 + /** Save an API key and create the sync client. */
101 + saveApiKey: (apiKey) => invoke('sync_save_api_key', { apiKey }),
98 102 /** Get current sync status. */
99 103 status: () => invoke('sync_status'),
100 104 /** Start OAuth2 PKCE auth flow, returns auth URL + PKCE params. */
@@ -38,21 +38,86 @@
38 38 }
39 39 }
40 40
41 - // ── State 1: Connect ──
41 + // ── State 1: API Key Setup ──
42 42
43 43 function renderConnect(container) {
44 44 const div = document.createElement('div');
45 45 div.className = 'sync-connect';
46 46 div.innerHTML =
47 - '<p>Connect to Makenot.work to sync your feeds, read state, and preferences across devices.</p>' +
48 - '<p>Data is end-to-end encrypted. The server never sees your plaintext data.</p>';
47 + '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' +
48 + '<p style="margin-bottom: 0.5rem;"><a href="https://makenot.work/docs/synckit-api" target="_blank">Get an API key</a></p>';
49 49
50 - const btn = document.createElement('button');
51 - btn.className = 'btn btn-primary';
52 - btn.textContent = 'Connect to Makenot.work';
53 - btn.onclick = startAuth;
54 - div.appendChild(btn);
50 + const group = document.createElement('div');
51 + group.className = 'form-group';
52 + const label = document.createElement('label');
53 + label.textContent = 'API Key';
54 + const row = document.createElement('div');
55 + row.style.display = 'flex';
56 + row.style.gap = '0.5rem';
57 + const input = document.createElement('input');
58 + input.className = 'form-input';
59 + input.type = 'password';
60 + input.placeholder = 'sk_...';
61 + input.style.flex = '1';
62 + const testBtn = document.createElement('button');
63 + testBtn.className = 'btn';
64 + testBtn.textContent = 'Test';
55 65
66 + const statusDiv = document.createElement('div');
67 + statusDiv.style.fontSize = '0.875rem';
68 + statusDiv.style.marginTop = '0.5rem';
69 + statusDiv.style.display = 'none';
70 +
71 + const saveBtn = document.createElement('button');
72 + saveBtn.className = 'btn btn-primary';
73 + saveBtn.textContent = 'Save & Connect';
74 + saveBtn.style.marginTop = '0.75rem';
75 + saveBtn.style.display = 'none';
76 +
77 + testBtn.onclick = async () => {
78 + const key = input.value.trim();
79 + if (!key) return;
80 + testBtn.disabled = true;
81 + testBtn.textContent = 'Testing...';
82 + statusDiv.style.display = 'block';
83 + statusDiv.textContent = 'Validating...';
84 + statusDiv.style.color = '';
85 + saveBtn.style.display = 'none';
86 + try {
87 + const appName = await BB.api.sync.testApiKey(key);
88 + statusDiv.style.color = 'var(--accent-green, green)';
89 + statusDiv.textContent = 'Valid \u2014 ' + appName;
90 + saveBtn.style.display = '';
91 + } catch (err) {
92 + statusDiv.style.color = 'var(--accent-red, red)';
93 + statusDiv.textContent = err.message || String(err);
94 + saveBtn.style.display = 'none';
95 + } finally {
96 + testBtn.disabled = false;
97 + testBtn.textContent = 'Test';
98 + }
99 + };
100 +
101 + saveBtn.onclick = async () => {
102 + const key = input.value.trim();
103 + if (!key) return;
104 + try {
105 + await BB.api.sync.saveApiKey(key);
106 + BB.ui.showToast('API key saved!', 'success');
107 + const status = await BB.api.sync.status();
108 + renderState(container, status);
109 + } catch (err) {
110 + BB.ui.showToast('Failed to save: ' + (err.message || err), 'error');
111 + }
112 + };
113 +
114 + row.appendChild(input);
115 + row.appendChild(testBtn);
116 + group.appendChild(label);
117 + group.appendChild(row);
118 + div.appendChild(group);
119 + div.appendChild(statusDiv);
120 + div.appendChild(saveBtn);
56 121 container.appendChild(div);
57 122 }
58 123
@@ -181,11 +181,15 @@
181 181
182 182 let name = validate_feed_input(&input.name, &input.config, &schema)?;
183 183
184 + // Serialize the plaintext config for the duplicate check before we move
185 + // `input.config` into the encryption step (avoids an extra clone).
186 + let new_config_str = serde_json::to_string(&input.config).unwrap_or_default();
187 +
184 188 let db = state.orchestrator.database();
185 189
186 - // Encrypt Secret-type fields before storage
190 + // Encrypt Secret-type fields before storage (mutates in place, no clone)
187 191 let config = {
188 - let mut cfg = input.config.clone();
192 + let mut cfg = input.config;
189 193 if let Some(key) = state.orchestrator.encryption_key() {
190 194 let plugins = state.orchestrator.plugins();
191 195 let plugins = plugins.read().await;
@@ -217,7 +221,6 @@
217 221 plugins.get_config_schema(&input.busser_id)
218 222 };
219 223
220 - let new_config_str = serde_json::to_string(&input.config).unwrap_or_default();
221 224 for feed in &existing {
222 225 let mut existing_config = feed.config_json();
223 226 // Decrypt existing config for comparison against plaintext input
@@ -337,22 +340,19 @@
337 340
338 341 let name = validate_feed_input(&name, &config, &schema)?;
339 342
340 - // Encrypt Secret-type fields before storage
341 - let encrypted_config = {
342 - let mut cfg = config.clone();
343 - if let Some(key) = state.orchestrator.encryption_key() {
344 - let plugins = state.orchestrator.plugins();
345 - let plugins = plugins.read().await;
346 - if let Some(schema) = plugins.get_config_schema(&busser_id) {
347 - bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key);
348 - }
343 + // Encrypt Secret-type fields before storage (mutate in place, no clone)
344 + let mut config = config;
345 + if let Some(key) = state.orchestrator.encryption_key() {
346 + let plugins = state.orchestrator.plugins();
347 + let plugins = plugins.read().await;
348 + if let Some(schema) = plugins.get_config_schema(&busser_id) {
349 + bb_core::crypto::encrypt_config_secrets(&mut config, &schema, key);
349 350 }
350 - cfg
351 - };
351 + }
352 352
353 353 // Update name and config
354 354 db.feeds().update_name(feed_id, &name).await?;
355 - let config_str = serde_json::to_string(&encrypted_config)
355 + let config_str = serde_json::to_string(&config)
356 356 .map_err(|e| ApiError::internal(format!("Failed to serialize config: {}", e)))?;
357 357 db.feeds().update_config(feed_id, &config_str).await?;
358 358
@@ -428,6 +428,13 @@
428 428 Ok(())
429 429 }
430 430
431 + /// Tag rules for Balanced Breakfast: shallow hierarchy, no required semantic prefix.
432 + const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
433 + max_depth: 3,
434 + max_length: 80,
435 + semantic_depth: 0,
436 + };
437 +
431 438 /// Set tags on all feeds belonging to a busser.
432 439 #[tauri::command]
433 440 #[instrument(skip_all)]
@@ -436,6 +443,11 @@
436 443 busser_id: String,
437 444 tags: Vec<String>,
438 445 ) -> Result<(), ApiError> {
446 + for tag in &tags {
447 + tagtree::validate_with(tag, &BB_TAG_CONFIG)
448 + .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?;
449 + }
450 +
439 451 let db = state.orchestrator.database();
440 452 let feeds = db.feeds().get_by_busser(&busser_id).await?;
441 453