Skip to main content

max / balanced_breakfast

docs: archive completed todo items to todo_done.md Move resolved UX audit and learnability items out of todo.md to keep the active list focused on open work (mobile gestures, plugin authoring docs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-13 03:35 UTC
Commit: c2880b7676e89a3ed26d4de202b06da21b1501f1
Parent: f1d84b8
2 files changed, +148 insertions, -127 deletions
M docs/todo.md +5 -127
@@ -9,31 +9,9 @@ v0.3.1. Audit grade A. 601 tests (`--workspace`). Rust 2024 edition (2026-05-06)
9 9
10 10 ## UX Audit Findings (2026-05-05)
11 11
12 - Usability audit across complexity, feature completeness, learnability, and discoverability.
13 - Overall grade: B. Grades: Complexity A-, Completeness B-, Learnability B-, Discoverability C+.
12 + ### Discoverability (remaining)
14 13
15 - ### Critical (blocks core workflows) — DONE
16 -
17 - - [x] **Separate unread filter from sort** — added "Unread" toggle button in toolbar (U key), independent of sort. `buildFilter` uses `BB.state.unreadOnly`. "All caught up!" empty state when no unread items.
18 - - [x] **Add mark-all-as-read** — backend `mark_all_read` in repository + command. Toolbar "Mark All Read" button (Shift+A) with confirmation. Per-source mark-all-read in feed popover. Reloads sources + items after.
19 -
20 - ### Discoverability — DONE (except mobile gestures)
21 -
22 - - [x] Add persistent `?` help button in header — visible button wired to existing help modal
23 14 - [ ] Add mobile gesture hints on first touch session — swipe right=star, left=read, pull-to-refresh. No visual affordance currently exists for any touch gesture.
24 - - [x] Show gear icon on sources always (not just on hover) — changed from `display:none` to `opacity:0.4`, full opacity on hover
25 - - [x] Rename "+ Query Feed" to "+ Saved Filter" with tooltip explaining what it does
26 - - [x] Add "Import OPML" link in the empty-state message alongside "+ Add Feed"
27 - - [x] Show keyboard shortcut hints as tooltips on toolbar buttons (Refresh, Sort, etc.)
28 -
29 - ### Learnability — DONE
30 -
31 - - [x] Add "Recommended" badge to plugin picker — rss, mastodon, reddit sorted first with yellow badge. Picker title changed to "Select a source type"
32 - - [x] Auto-trigger refresh after adding first feed — `refresh()` called after `create_feed`, toast says "Fetching articles..."
33 - - [x] Change empty-state message when feeds exist but items are empty: "Feeds added but no articles yet. Click Refresh to fetch posts."
34 - - [x] Standardize delete confirmations — bookmarks.js `confirm()` replaced with `BB.ui.confirmAction()`
35 - - [x] Clarify sync encryption setup — "First device setup" vs "Unlock this device" with clear instructions
36 - - [x] Rename "Reading List" to "Saved Articles"
37 15
38 16 ### Feature Completeness
39 17
@@ -49,123 +27,23 @@ Overall grade: B. Grades: Complexity A-, Completeness B-, Learnability B-, Disco
49 27
50 28 ---
51 29
52 - ## Sync Monetization
53 -
54 - BB is free. Cloud sync is the only revenue source. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale.
30 + ## Sync Monetization (remaining)
55 31
56 - - [x] Stripe pricing: inline price_data ($1/mo, $8/yr), no pre-created products needed
57 - - [x] Sync gate: server returns 402, scheduler backs off 1 hour + emits `sync:subscription-required`
58 - - [x] Subscription UI: banner in sync modal with Annual/Monthly buttons, opens Stripe checkout in browser
59 32 - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency)
60 33 - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → sync gate passes)
61 34
62 - ## SyncKit Parity with GoingsOn (2026-05-11)
63 -
64 - Fixes needed to match GO's working end-to-end SyncKit flow:
35 + ## SyncKit Parity with GoingsOn (2026-05-11) (remaining)
65 36
66 37 - [ ] **synckit.toml** — create `synckit.toml` with BB's API key (need to create sync app on MNW dashboard first). Replace `option_env!("SYNCKIT_API_KEY")` in state.rs with `include_str!("../../synckit.toml")` + parser. Current approach breaks silently on recompile without env var.
67 - - [ ] **OAuth callback CORS** — `commands/sync.rs` callback server responses missing `Access-Control-Allow-Origin: *` header. Tauri origin `tauri://localhost` blocked from polling `http://127.0.0.1:{port}/result`.
68 - - [ ] **OAuth poll loop** — `settings-sync.js` uses manual "Check Status" button instead of automatic polling. Replace with 1s interval poll loop that skips `status: "pending"` responses (copy pattern from GO's `pollSyncAuthResult`).
69 - - [ ] **CSP blocks localhost** — `tauri.conf.json` CSP missing `http://127.0.0.1` in `connect-src`. Either add it or revert CSP to `null` (app uses inline handlers throughout).
70 -
71 - ---
72 -
73 - ## Fuzz Findings (2026-04-27)
74 -
75 - Findings from adversarial code review. Ordered by severity.
76 -
77 - ### HIGH
78 -
79 - - [x] **XSS in bookmark HTML export** (`commands/bookmarks.rs:403`). The `body` field (attacker-controlled RSS content) is interpolated into the HTML template without escaping. `title`/`url`/`author` are escaped but `body` is not. Fix: pass `body` through `html_escape` or sanitize.
80 - - [x] **Path traversal in `download_and_open`** (`commands/items.rs:384-388`). URL filename extracted via `rsplit('/')` is passed to `dir.join()` without sanitizing `..` components. Fix: strip path separators and `..` from derived filename.
81 - - [x] **Arbitrary code exec via `open::that`** (`commands/items.rs:404`). Downloaded files are opened with the system default handler with no extension validation. A `.exe`/`.command`/`.scpt` URL would be downloaded and launched. Fix: allowlist safe extensions or skip auto-open for dangerous ones.
82 - - [x] **Source+unread/starred filter bypass** (`bb-feed/generator/query.rs:29-61`). The `if/else if` chain gives `source` priority over `unread_only`/`starred_only`. When both are set (no search), the unread/starred filter is silently dropped. Fix: add `list_by_busser_unread`/`list_by_busser_starred` queries, or combine the filters in the existing chain.
83 - - [x] **Regex recompilation per-item** (`ordering.rs:197` + `query.rs:103`). `retain` calls `matches()` which calls `compile_regexes()` per item. O(N×M) regex compilations. Fix: pre-compile once before the `retain` loop.
84 -
85 - ### MEDIUM — sync service
86 -
87 - - [x] **No mutex on `perform_sync`** (`sync_service/mod.rs`, `sync_scheduler.rs`, `commands/sync.rs`). Fixed: added `tokio::sync::Mutex` in `AppState`, acquired in both `sync_now` and `sync_scheduler`.
88 - - [x] **Skipped changelog entries still marked as pushed** (`sync_service/upload.rs:77`). Fixed: track individual pushed IDs; only mark those as pushed. Skipped entries remain `pushed=0` and will be retried.
89 - - [x] **`INSERT OR REPLACE` with partial JSON nulls columns** (`sync_service/download.rs:158-199`). Fixed: validate primary key columns exist before upserting; log debug warning for other missing columns so they're visible but don't block the sync.
90 - - [x] **`applying_remote` flag not in transaction** (`sync_service/download.rs:53-58`). Fixed: flag set, data changes, and flag clear are all wrapped in a single SQLite transaction. A crash mid-apply rolls back the flag too.
91 -
92 - ### MEDIUM — crypto
93 -
94 - - [x] **TOCTOU race in `load_or_create_key`** (`crypto.rs:29-51`). Fixed: uses `OpenOptions::create_new(true)` for atomic check-and-create.
95 - - [x] **Keychain migration deletes file before verifying durability** (`crypto.rs:78-82`). Fixed: read-back and compare before deleting file.
96 - - [x] **Key creation failure silently degrades to plaintext** (`state.rs:68-76`). Fixed: encryption key loading is now a hard startup error. App will not start without a working encryption key.
97 - - [x] **Decrypt failure passes raw ciphertext to plugins** (`crypto.rs:199-200`). Fixed: clears field to empty string on decrypt failure instead of passing ciphertext through.
98 - - [x] **No key zeroing on drop** (`crypto.rs:21-25`). Fixed: all key material wrapped in `zeroize::Zeroizing<[u8; 32]>` via `EncryptionKey` type alias. Keys are zeroed from memory when dropped.
99 -
100 - ### MEDIUM — database
101 -
102 - - [x] **Missing transaction in `BookmarksRepository::create`** (`repository.rs:1047-1085`). Bookmark insert + tag inserts are not transactional; partial failure leaves bookmark with incomplete tags.
103 - - [x] **Missing transaction in `BookmarksRepository::set_tags`** (`repository.rs:1180-1198`). Delete-all + inserts not transactional; failure mid-insert loses tags permanently.
104 - - [x] **`update_config` does not update `updated_at`** (`repository.rs:316-323`). Every other mutation method updates `updated_at`; this one doesn't, which could break sync/cache-invalidation.
105 -
106 - ### MEDIUM — plugin sandbox
107 -
108 - - [x] **No `set_max_string_size`/`set_max_array_size` on Rhai engine** (`rhai_plugin/mod.rs:271-274`). Fixed: set `max_string_size` (2 MB), `max_array_size` (5000), `max_map_size` (2000) on both engine creation sites. Complements the existing per-node `validate_dynamic_sizes` on return values.
109 - - [x] **Rhai `import` may not be disabled** (`rhai_plugin/mod.rs:271`). Fixed: set `DummyModuleResolver` on both `load_plugin` and `create_engine` engines.
110 - - [x] **DNS rebinding bypasses URL blocklist** (`rhai_plugin/host_functions.rs:35-86`). Fixed: strip `user@` from host before blocklist check; added `100.x` (CGNAT/Tailscale) block. Note: DNS rebinding itself is inherent to string-level checks; mitigated by trust model (local plugins only).
111 -
112 - ### MEDIUM — other
113 -
114 - - [x] **`count()` ignores most filters** (`bb-feed/generator/query.rs:160-168`). Fixed: added `starred_only` support and combined source+unread/starred via `list_filtered`.
115 - - [x] **`get_all_items()` ignores `feed_tags` filter** (`bb-feed/generator/query.rs:137-156`). Fixed: added feed_tags filtering before `apply()`.
116 - - [x] **No URL scheme validation in `extract_reader_view`** (`commands/query_feeds.rs:192-210`). Fixed: validates http/https before calling reader script.
117 - - [x] **HTML URL cleaner only matches double-quoted attributes** (`url_cleaner.rs:88`). Single-quoted URLs (`href='...'`) bypass tracking parameter removal entirely.
118 -
119 - ### LOW / NOTE
120 -
121 - - [x] **No size limit on OPML import** (`commands/opml.rs:70`). Fixed: capped at 10 MB before parsing.
122 - - [x] **Bookmark duplicate check not transactional** (`commands/bookmarks.rs:143-145`). Kept check-then-insert (no UNIQUE constraint on url column). Acceptable for desktop single-user app. Documented.
123 - - [x] **No bookmark tag validation** (`commands/bookmarks.rs:274-291`). Fixed: added `validate_bookmark_tags` using same `tagtree::validate_with` rules as feed tags. Applied to `create_bookmark`, `create_bookmark_from_item`, and `set_bookmark_tags`.
124 - - [x] **Error leakage**: raw sqlx errors forwarded to frontend (`commands/error.rs:81-84`). Fixed: log full error server-side, send generic message to frontend.
125 - - [x] **`expect()` on `AppState::new()` in startup** (`lib.rs:39`). Fixed: replaced `expect` with `match` that returns `Err` to Tauri setup, logging + printing the error. No more panic on DB/key failure.
126 - - [x] **`serde_json::to_string` fallback replaces config with `{}`** (`orchestrator.rs:389-390`). Fixed: serialization failure now logs an error and skips the update, preserving existing config.
127 - - [x] **`FeedItemId::to_combined`/`from_combined` roundtrip fails when source contains `:`** (`feed_item.rs:24-32`). Fixed: added `debug_assert` that source must not contain `:`. Documented the invariant. Source IDs are simple ASCII identifiers by convention.
128 38
129 39 ---
130 40
131 - ## Fuzz Findings (2026-05-02)
132 -
133 - Findings from adversarial code review. Ordered by severity.
134 -
135 - ### SERIOUS
136 -
137 - - [x] **`UnreadFirst` sorts read items first** (`ordering.rs:57-61`). Fixed: swapped to `a.is_read.cmp(&b.is_read)`. Fixed test to assert correct ordering.
138 - - [x] **`PRAGMA foreign_keys` never enabled** (`bb-db/src/lib.rs:26-31`). Fixed: added `after_connect` hook that runs `PRAGMA foreign_keys = ON` on every connection.
139 - - [x] **Feed edit "Save" is broken** (`sources.js:470`). Fixed: pass `feed.id` (UUID) instead of `source.id` (busser_id).
140 - - [x] **SSRF via HTTP redirect** (`host_functions.rs:183-196`). Fixed: created shared `ureq::Agent` with `redirects(0)`. Plugins no longer follow redirects.
141 - - [x] **Error response body read without size limit** (`host_functions.rs:133-157`). Fixed: `format_ureq_error` now reads error bodies via `.into_reader().take(MAX_RESPONSE_BYTES)`.
142 - - [x] **Tokio RwLock held across blocking HTTP** (`orchestrator.rs:199-202`). Fixed: wrapped `plugins.fetch()` in `tokio::task::spawn_blocking`.
143 - - [x] **Key file world-readable between write and chmod** (`crypto.rs:46-53`). Fixed: use `OpenOptionsExt::mode(0o600)` on Unix to set permissions atomically at creation.
144 - - [x] **Raw SQLite errors leaked to frontend in sync service** (`sync_service/*.rs`). Fixed: added `db_err()` helper that logs full error and returns generic message. Applied to all 27 call sites.
145 -
146 - ### MINOR
147 -
148 - - [x] **`count()` loads all rows into memory** (`generator/query.rs:162-176`). Fixed: added `count_filtered()` to `ItemsRepository` using `SELECT COUNT(*)` with dynamic WHERE clause.
149 - - [x] **`100.` prefix blocking overly broad** (`host_functions.rs:63`). Fixed: replaced with `is_cgnat()` that checks `100.64.0.0/10` only.
150 - - [x] **`validate_url` doesn't block encoded IPs** (`host_functions.rs:35-88`). Fixed: block hex-prefixed hosts, pure-numeric hosts (decimal IPs), and octal-prefixed octets.
151 - - [ ] ~~**Non-deterministic synthesized IDs** (`conversions.rs:280-286`)~~. False positive: `DefaultHasher::new()` uses fixed keys and IS deterministic across runs.
152 - - [x] **`truncate` with negative `max_len`** (`host_functions.rs:272-283`). Fixed: `max_len.max(0)` before cast to usize.
153 - - [x] **`bookmark_tags` INSERT without `OR IGNORE`** (`repository.rs:1253`). Fixed: added `OR IGNORE`.
154 - - [x] **Only first feed per plugin used** (`orchestrator.rs:189`). Fixed: track all feed IDs; record success/failure for every feed, not just first.
155 - - [x] **Decryption failure silently clears secret** (`crypto.rs:228-230`). Fixed: upgraded log level to `error` so it's visible in logs. The clear behavior is intentional (prevents ciphertext leakage to plugins).
156 - - [x] **Initial snapshot race** (`sync_scheduler.rs:150-165`). Fixed: moved snapshot creation after acquiring sync mutex.
157 - - [x] **Temp files never cleaned up** (`commands/items.rs:410-414`). Fixed: clean up `bb-downloads/` on startup; use PID-prefixed filenames to prevent collisions.
158 - - [x] **OAuth callback thread leak** (`commands/sync.rs:114-159`). Fixed: generation counter cancels previous callback servers when a new auth flow starts.
159 - - [x] **`Score` ordering NULL as 0** (`ordering.rs:46-52`). Fixed: use `i64::MIN` for None so scoreless items sort after all scored items.
41 + ## Fuzz Findings (2026-05-02) — remaining
160 42
161 - ### NOTE
43 + ### NOTE (remaining)
162 44
163 45 - [ ] **Aggregate memory per fetch uncapped** (`host_functions.rs:21,25`). 100 requests × 2MB = 200MB during execution. Acceptable: per-plugin, user-installed, single-threaded.
164 - - [x] **`run_reader_script` no timeout/validation** (`rhai_plugin/mod.rs:354-369`). Fixed: reader scripts now use 60-second aggregate deadline via `create_engine_inner(true)`.
165 - - [x] **`sanitizeHtml` missing `<meta>` tag** (`frontend/js/utils.js:21`). Fixed: added `meta` and `link` to `DANGEROUS_ELEMENTS`.
166 46 - [ ] ~~**FTS5 rowid instability after VACUUM**~~ (`migrations/sqlite/005_create_fts.sql`). Won't fix: app never runs VACUUM. Documented for awareness.
167 - - [x] **`get_all_items` fetches then filters** (`generator/query.rs:119-153`). Fixed: use `list_filtered()` to push source/unread/starred/search to SQL.
168 - - [x] **Plugin errors leak internals** (`commands/error.rs:104`). Fixed: log full error server-side, send generic message to frontend.
169 47
170 48 ---
171 49
@@ -0,0 +1,143 @@
1 + # Balanced Breakfast — Completed Items
2 +
3 + Items moved from todo.md to keep the active list focused on open work.
4 +
5 + ---
6 +
7 + ## UX Audit Findings (2026-05-05)
8 +
9 + Usability audit across complexity, feature completeness, learnability, and discoverability.
10 + Overall grade: B. Grades: Complexity A-, Completeness B-, Learnability B-, Discoverability C+.
11 +
12 + ### Critical (blocks core workflows) — DONE
13 +
14 + - [x] **Separate unread filter from sort** — added "Unread" toggle button in toolbar (U key), independent of sort. `buildFilter` uses `BB.state.unreadOnly`. "All caught up!" empty state when no unread items.
15 + - [x] **Add mark-all-as-read** — backend `mark_all_read` in repository + command. Toolbar "Mark All Read" button (Shift+A) with confirmation. Per-source mark-all-read in feed popover. Reloads sources + items after.
16 +
17 + ### Discoverability (completed items)
18 +
19 + - [x] Add persistent `?` help button in header — visible button wired to existing help modal
20 + - [x] Show gear icon on sources always (not just on hover) — changed from `display:none` to `opacity:0.4`, full opacity on hover
21 + - [x] Rename "+ Query Feed" to "+ Saved Filter" with tooltip explaining what it does
22 + - [x] Add "Import OPML" link in the empty-state message alongside "+ Add Feed"
23 + - [x] Show keyboard shortcut hints as tooltips on toolbar buttons (Refresh, Sort, etc.)
24 +
25 + ### Learnability — DONE
26 +
27 + - [x] Add "Recommended" badge to plugin picker — rss, mastodon, reddit sorted first with yellow badge. Picker title changed to "Select a source type"
28 + - [x] Auto-trigger refresh after adding first feed — `refresh()` called after `create_feed`, toast says "Fetching articles..."
29 + - [x] Change empty-state message when feeds exist but items are empty: "Feeds added but no articles yet. Click Refresh to fetch posts."
30 + - [x] Standardize delete confirmations — bookmarks.js `confirm()` replaced with `BB.ui.confirmAction()`
31 + - [x] Clarify sync encryption setup — "First device setup" vs "Unlock this device" with clear instructions
32 + - [x] Rename "Reading List" to "Saved Articles"
33 +
34 + ---
35 +
36 + ## Sync Monetization (completed items)
37 +
38 + - [x] Stripe pricing: inline price_data ($1/mo, $8/yr), no pre-created products needed
39 + - [x] Sync gate: server returns 402, scheduler backs off 1 hour + emits `sync:subscription-required`
40 + - [x] Subscription UI: banner in sync modal with Annual/Monthly buttons, opens Stripe checkout in browser
41 +
42 + ## SyncKit Parity with GoingsOn (2026-05-11) (completed items)
43 +
44 + - [x] **OAuth callback CORS** — `commands/sync.rs` callback server responses missing `Access-Control-Allow-Origin: *` header. Tauri origin `tauri://localhost` blocked from polling `http://127.0.0.1:{port}/result`.
45 + - [x] **OAuth poll loop** — `settings-sync.js` uses manual "Check Status" button instead of automatic polling. Replace with 1s interval poll loop that skips `status: "pending"` responses (copy pattern from GO's `pollSyncAuthResult`).
46 + - [x] **CSP blocks localhost** — `tauri.conf.json` CSP missing `http://127.0.0.1` in `connect-src`. Either add it or revert CSP to `null` (app uses inline handlers throughout).
47 +
48 + ---
49 +
50 + ## Fuzz Findings (2026-04-27)
51 +
52 + Findings from adversarial code review. Ordered by severity.
53 +
54 + ### HIGH
55 +
56 + - [x] **XSS in bookmark HTML export** (`commands/bookmarks.rs:403`). The `body` field (attacker-controlled RSS content) is interpolated into the HTML template without escaping. `title`/`url`/`author` are escaped but `body` is not. Fix: pass `body` through `html_escape` or sanitize.
57 + - [x] **Path traversal in `download_and_open`** (`commands/items.rs:384-388`). URL filename extracted via `rsplit('/')` is passed to `dir.join()` without sanitizing `..` components. Fix: strip path separators and `..` from derived filename.
58 + - [x] **Arbitrary code exec via `open::that`** (`commands/items.rs:404`). Downloaded files are opened with the system default handler with no extension validation. A `.exe`/`.command`/`.scpt` URL would be downloaded and launched. Fix: allowlist safe extensions or skip auto-open for dangerous ones.
59 + - [x] **Source+unread/starred filter bypass** (`bb-feed/generator/query.rs:29-61`). The `if/else if` chain gives `source` priority over `unread_only`/`starred_only`. When both are set (no search), the unread/starred filter is silently dropped. Fix: add `list_by_busser_unread`/`list_by_busser_starred` queries, or combine the filters in the existing chain.
60 + - [x] **Regex recompilation per-item** (`ordering.rs:197` + `query.rs:103`). `retain` calls `matches()` which calls `compile_regexes()` per item. O(N×M) regex compilations. Fix: pre-compile once before the `retain` loop.
61 +
62 + ### MEDIUM — sync service
63 +
64 + - [x] **No mutex on `perform_sync`** (`sync_service/mod.rs`, `sync_scheduler.rs`, `commands/sync.rs`). Fixed: added `tokio::sync::Mutex` in `AppState`, acquired in both `sync_now` and `sync_scheduler`.
65 + - [x] **Skipped changelog entries still marked as pushed** (`sync_service/upload.rs:77`). Fixed: track individual pushed IDs; only mark those as pushed. Skipped entries remain `pushed=0` and will be retried.
66 + - [x] **`INSERT OR REPLACE` with partial JSON nulls columns** (`sync_service/download.rs:158-199`). Fixed: validate primary key columns exist before upserting; log debug warning for other missing columns so they're visible but don't block the sync.
67 + - [x] **`applying_remote` flag not in transaction** (`sync_service/download.rs:53-58`). Fixed: flag set, data changes, and flag clear are all wrapped in a single SQLite transaction. A crash mid-apply rolls back the flag too.
68 +
69 + ### MEDIUM — crypto
70 +
71 + - [x] **TOCTOU race in `load_or_create_key`** (`crypto.rs:29-51`). Fixed: uses `OpenOptions::create_new(true)` for atomic check-and-create.
72 + - [x] **Keychain migration deletes file before verifying durability** (`crypto.rs:78-82`). Fixed: read-back and compare before deleting file.
73 + - [x] **Key creation failure silently degrades to plaintext** (`state.rs:68-76`). Fixed: encryption key loading is now a hard startup error. App will not start without a working encryption key.
74 + - [x] **Decrypt failure passes raw ciphertext to plugins** (`crypto.rs:199-200`). Fixed: clears field to empty string on decrypt failure instead of passing ciphertext through.
75 + - [x] **No key zeroing on drop** (`crypto.rs:21-25`). Fixed: all key material wrapped in `zeroize::Zeroizing<[u8; 32]>` via `EncryptionKey` type alias. Keys are zeroed from memory when dropped.
76 +
77 + ### MEDIUM — database
78 +
79 + - [x] **Missing transaction in `BookmarksRepository::create`** (`repository.rs:1047-1085`). Bookmark insert + tag inserts are not transactional; partial failure leaves bookmark with incomplete tags.
80 + - [x] **Missing transaction in `BookmarksRepository::set_tags`** (`repository.rs:1180-1198`). Delete-all + inserts not transactional; failure mid-insert loses tags permanently.
81 + - [x] **`update_config` does not update `updated_at`** (`repository.rs:316-323`). Every other mutation method updates `updated_at`; this one doesn't, which could break sync/cache-invalidation.
82 +
83 + ### MEDIUM — plugin sandbox
84 +
85 + - [x] **No `set_max_string_size`/`set_max_array_size` on Rhai engine** (`rhai_plugin/mod.rs:271-274`). Fixed: set `max_string_size` (2 MB), `max_array_size` (5000), `max_map_size` (2000) on both engine creation sites. Complements the existing per-node `validate_dynamic_sizes` on return values.
86 + - [x] **Rhai `import` may not be disabled** (`rhai_plugin/mod.rs:271`). Fixed: set `DummyModuleResolver` on both `load_plugin` and `create_engine` engines.
87 + - [x] **DNS rebinding bypasses URL blocklist** (`rhai_plugin/host_functions.rs:35-86`). Fixed: strip `user@` from host before blocklist check; added `100.x` (CGNAT/Tailscale) block. Note: DNS rebinding itself is inherent to string-level checks; mitigated by trust model (local plugins only).
88 +
89 + ### MEDIUM — other
90 +
91 + - [x] **`count()` ignores most filters** (`bb-feed/generator/query.rs:160-168`). Fixed: added `starred_only` support and combined source+unread/starred via `list_filtered`.
92 + - [x] **`get_all_items()` ignores `feed_tags` filter** (`bb-feed/generator/query.rs:137-156`). Fixed: added feed_tags filtering before `apply()`.
93 + - [x] **No URL scheme validation in `extract_reader_view`** (`commands/query_feeds.rs:192-210`). Fixed: validates http/https before calling reader script.
94 + - [x] **HTML URL cleaner only matches double-quoted attributes** (`url_cleaner.rs:88`). Single-quoted URLs (`href='...'`) bypass tracking parameter removal entirely.
95 +
96 + ### LOW / NOTE (completed items)
97 +
98 + - [x] **No size limit on OPML import** (`commands/opml.rs:70`). Fixed: capped at 10 MB before parsing.
99 + - [x] **Bookmark duplicate check not transactional** (`commands/bookmarks.rs:143-145`). Kept check-then-insert (no UNIQUE constraint on url column). Acceptable for desktop single-user app. Documented.
100 + - [x] **No bookmark tag validation** (`commands/bookmarks.rs:274-291`). Fixed: added `validate_bookmark_tags` using same `tagtree::validate_with` rules as feed tags. Applied to `create_bookmark`, `create_bookmark_from_item`, and `set_bookmark_tags`.
101 + - [x] **Error leakage**: raw sqlx errors forwarded to frontend (`commands/error.rs:81-84`). Fixed: log full error server-side, send generic message to frontend.
102 + - [x] **`expect()` on `AppState::new()` in startup** (`lib.rs:39`). Fixed: replaced `expect` with `match` that returns `Err` to Tauri setup, logging + printing the error. No more panic on DB/key failure.
103 + - [x] **`serde_json::to_string` fallback replaces config with `{}`** (`orchestrator.rs:389-390`). Fixed: serialization failure now logs an error and skips the update, preserving existing config.
104 + - [x] **`FeedItemId::to_combined`/`from_combined` roundtrip fails when source contains `:`** (`feed_item.rs:24-32`). Fixed: added `debug_assert` that source must not contain `:`. Documented the invariant. Source IDs are simple ASCII identifiers by convention.
105 +
106 + ---
107 +
108 + ## Fuzz Findings (2026-05-02)
109 +
110 + Findings from adversarial code review. Ordered by severity.
111 +
112 + ### SERIOUS
113 +
114 + - [x] **`UnreadFirst` sorts read items first** (`ordering.rs:57-61`). Fixed: swapped to `a.is_read.cmp(&b.is_read)`. Fixed test to assert correct ordering.
115 + - [x] **`PRAGMA foreign_keys` never enabled** (`bb-db/src/lib.rs:26-31`). Fixed: added `after_connect` hook that runs `PRAGMA foreign_keys = ON` on every connection.
116 + - [x] **Feed edit "Save" is broken** (`sources.js:470`). Fixed: pass `feed.id` (UUID) instead of `source.id` (busser_id).
117 + - [x] **SSRF via HTTP redirect** (`host_functions.rs:183-196`). Fixed: created shared `ureq::Agent` with `redirects(0)`. Plugins no longer follow redirects.
118 + - [x] **Error response body read without size limit** (`host_functions.rs:133-157`). Fixed: `format_ureq_error` now reads error bodies via `.into_reader().take(MAX_RESPONSE_BYTES)`.
119 + - [x] **Tokio RwLock held across blocking HTTP** (`orchestrator.rs:199-202`). Fixed: wrapped `plugins.fetch()` in `tokio::task::spawn_blocking`.
120 + - [x] **Key file world-readable between write and chmod** (`crypto.rs:46-53`). Fixed: use `OpenOptionsExt::mode(0o600)` on Unix to set permissions atomically at creation.
121 + - [x] **Raw SQLite errors leaked to frontend in sync service** (`sync_service/*.rs`). Fixed: added `db_err()` helper that logs full error and returns generic message. Applied to all 27 call sites.
122 +
123 + ### MINOR (completed items)
124 +
125 + - [x] **`count()` loads all rows into memory** (`generator/query.rs:162-176`). Fixed: added `count_filtered()` to `ItemsRepository` using `SELECT COUNT(*)` with dynamic WHERE clause.
126 + - [x] **`100.` prefix blocking overly broad** (`host_functions.rs:63`). Fixed: replaced with `is_cgnat()` that checks `100.64.0.0/10` only.
127 + - [x] **`validate_url` doesn't block encoded IPs** (`host_functions.rs:35-88`). Fixed: block hex-prefixed hosts, pure-numeric hosts (decimal IPs), and octal-prefixed octets.
128 + - [ ] ~~**Non-deterministic synthesized IDs** (`conversions.rs:280-286`)~~. False positive: `DefaultHasher::new()` uses fixed keys and IS deterministic across runs.
129 + - [x] **`truncate` with negative `max_len`** (`host_functions.rs:272-283`). Fixed: `max_len.max(0)` before cast to usize.
130 + - [x] **`bookmark_tags` INSERT without `OR IGNORE`** (`repository.rs:1253`). Fixed: added `OR IGNORE`.
131 + - [x] **Only first feed per plugin used** (`orchestrator.rs:189`). Fixed: track all feed IDs; record success/failure for every feed, not just first.
132 + - [x] **Decryption failure silently clears secret** (`crypto.rs:228-230`). Fixed: upgraded log level to `error` so it's visible in logs. The clear behavior is intentional (prevents ciphertext leakage to plugins).
133 + - [x] **Initial snapshot race** (`sync_scheduler.rs:150-165`). Fixed: moved snapshot creation after acquiring sync mutex.
134 + - [x] **Temp files never cleaned up** (`commands/items.rs:410-414`). Fixed: clean up `bb-downloads/` on startup; use PID-prefixed filenames to prevent collisions.
135 + - [x] **OAuth callback thread leak** (`commands/sync.rs:114-159`). Fixed: generation counter cancels previous callback servers when a new auth flow starts.
136 + - [x] **`Score` ordering NULL as 0** (`ordering.rs:46-52`). Fixed: use `i64::MIN` for None so scoreless items sort after all scored items.
137 +
138 + ### NOTE (completed items)
139 +
140 + - [x] **`run_reader_script` no timeout/validation** (`rhai_plugin/mod.rs:354-369`). Fixed: reader scripts now use 60-second aggregate deadline via `create_engine_inner(true)`.
141 + - [x] **`sanitizeHtml` missing `<meta>` tag** (`frontend/js/utils.js:21`). Fixed: added `meta` and `link` to `DANGEROUS_ELEMENTS`.
142 + - [x] **`get_all_items` fetches then filters** (`generator/query.rs:119-153`). Fixed: use `list_filtered()` to push source/unread/starred/search to SQL.
143 + - [x] **Plugin errors leak internals** (`commands/error.rs:104`). Fixed: log full error server-side, send generic message to frontend.