| 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 |
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 |
|
|