max / balanced_breakfast
12 files changed,
+0 insertions,
-1575 deletions
| @@ -1,324 +0,0 @@ | |||
| 1 | - | # Plugin Authoring Guide | |
| 2 | - | ||
| 3 | - | Balanced Breakfast plugins are Rhai scripts (`.rhai` files) that fetch content from external sources. Each plugin implements a small interface, uses host functions for HTTP and parsing, and returns structured feed items. No Rust compilation required. | |
| 4 | - | ||
| 5 | - | ## Required Functions | |
| 6 | - | ||
| 7 | - | Every plugin must define these four functions: | |
| 8 | - | ||
| 9 | - | ### `fn id() -> String` | |
| 10 | - | ||
| 11 | - | A unique identifier for this plugin type. Used as a namespace in item IDs and database storage. Must be stable across versions. | |
| 12 | - | ||
| 13 | - | ```rhai | |
| 14 | - | fn id() { | |
| 15 | - | "my-source" | |
| 16 | - | } | |
| 17 | - | ``` | |
| 18 | - | ||
| 19 | - | ### `fn name() -> String` | |
| 20 | - | ||
| 21 | - | A human-readable display name shown in the UI. | |
| 22 | - | ||
| 23 | - | ```rhai | |
| 24 | - | fn name() { | |
| 25 | - | "My Source" | |
| 26 | - | } | |
| 27 | - | ``` | |
| 28 | - | ||
| 29 | - | ### `fn config_schema() -> Map` | |
| 30 | - | ||
| 31 | - | Returns a map describing the configuration fields the user must fill in when adding a feed. The UI generates a form from this schema. | |
| 32 | - | ||
| 33 | - | ```rhai | |
| 34 | - | fn config_schema() { | |
| 35 | - | #{ | |
| 36 | - | description: "Fetch posts from My Source.", | |
| 37 | - | fields: [ | |
| 38 | - | #{ | |
| 39 | - | key: "feed_url", | |
| 40 | - | label: "Feed URL", | |
| 41 | - | field_type: "url", | |
| 42 | - | required: true, | |
| 43 | - | description: "The URL to fetch", | |
| 44 | - | placeholder: "https://example.com/feed" | |
| 45 | - | } | |
| 46 | - | ] | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ``` | |
| 50 | - | ||
| 51 | - | See "Config Schema Field Types" below for all supported `field_type` values. | |
| 52 | - | ||
| 53 | - | ### `fn fetch(config, cursor) -> Map` | |
| 54 | - | ||
| 55 | - | The core function. Called by the runtime to retrieve feed items. | |
| 56 | - | ||
| 57 | - | **Parameters:** | |
| 58 | - | - `config` -- A map containing the user's configuration values (keyed by the field `key` from the schema). Also contains a `feeds` array if additional feed URLs were configured. | |
| 59 | - | - `cursor` -- A string for pagination (the `next_cursor` from a previous fetch), or `()` (Rhai's unit/nil) on the first call. | |
| 60 | - | ||
| 61 | - | **Must return a map with:** | |
| 62 | - | - `items` -- An array of item maps (see "Item Structure" below) | |
| 63 | - | - `has_more` -- Boolean indicating whether more pages are available | |
| 64 | - | - `next_cursor` -- (Optional) String cursor for the next page | |
| 65 | - | ||
| 66 | - | ```rhai | |
| 67 | - | fn fetch(config, cursor) { | |
| 68 | - | let xml = http_get(config.feed_url); | |
| 69 | - | let feed = parse_feed(xml); | |
| 70 | - | ||
| 71 | - | let items = []; | |
| 72 | - | for entry in feed.entries { | |
| 73 | - | items.push(#{ | |
| 74 | - | id: #{ source: "my-source", item_id: entry.id }, | |
| 75 | - | bite: #{ | |
| 76 | - | author: "My Source", | |
| 77 | - | text: truncate(entry.title, 100), | |
| 78 | - | indicator: ">>>" | |
| 79 | - | }, | |
| 80 | - | content: #{ | |
| 81 | - | title: entry.title, | |
| 82 | - | body: entry.summary, | |
| 83 | - | url: entry.link | |
| 84 | - | }, | |
| 85 | - | meta: #{ | |
| 86 | - | source_name: "My Source", | |
| 87 | - | published_at: entry.published, | |
| 88 | - | tags: [] | |
| 89 | - | } | |
| 90 | - | }); | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | #{ items: items, has_more: false } | |
| 94 | - | } | |
| 95 | - | ``` | |
| 96 | - | ||
| 97 | - | ## Item Structure | |
| 98 | - | ||
| 99 | - | Each item in the `items` array must be a map with these four keys: | |
| 100 | - | ||
| 101 | - | ### `id` | |
| 102 | - | ||
| 103 | - | | Field | Type | Description | | |
| 104 | - | |-------|------|-------------| | |
| 105 | - | | `source` | String | Must match the plugin's `id()` return value | | |
| 106 | - | | `item_id` | String | Unique within this source (URL, GUID, or other stable ID) | | |
| 107 | - | ||
| 108 | - | ### `bite` | |
| 109 | - | ||
| 110 | - | Compact display shown in the item list. | |
| 111 | - | ||
| 112 | - | | Field | Type | Description | | |
| 113 | - | |-------|------|-------------| | |
| 114 | - | | `author` | String | Attribution line (feed name, username, etc.) | | |
| 115 | - | | `text` | String | Primary display text (truncated title or summary) | | |
| 116 | - | | `secondary` | String or `()` | Optional secondary info (score, comment count) | | |
| 117 | - | | `indicator` | String or `()` | Optional type indicator (emoji or short string) | | |
| 118 | - | ||
| 119 | - | ### `content` | |
| 120 | - | ||
| 121 | - | Full content shown in the detail panel. | |
| 122 | - | ||
| 123 | - | | Field | Type | Description | | |
| 124 | - | |-------|------|-------------| | |
| 125 | - | | `title` | String or `()` | Article/post title | | |
| 126 | - | | `body` | String or `()` | Full body text or HTML | | |
| 127 | - | | `url` | String or `()` | Link to the original content | | |
| 128 | - | ||
| 129 | - | ### `meta` | |
| 130 | - | ||
| 131 | - | | Field | Type | Description | | |
| 132 | - | |-------|------|-------------| | |
| 133 | - | | `source_name` | String | Display name for the source | | |
| 134 | - | | `published_at` | Integer | Unix timestamp (seconds) of publication | | |
| 135 | - | | `score` | Integer or `()` | Optional engagement score (upvotes, likes) | | |
| 136 | - | | `tags` | Array | Array of tag strings (can be empty) | | |
| 137 | - | ||
| 138 | - | ## Host Functions | |
| 139 | - | ||
| 140 | - | These functions are registered into the Rhai engine and available to all plugins. | |
| 141 | - | ||
| 142 | - | ### HTTP | |
| 143 | - | ||
| 144 | - | | Function | Signature | Description | | |
| 145 | - | |----------|-----------|-------------| | |
| 146 | - | | `http_get` | `(url: String) -> String` | Fetch a URL, return the response body as a string | | |
| 147 | - | | `http_get_json` | `(url: String) -> Map` | Fetch a URL, parse the response as JSON, return a Rhai map | | |
| 148 | - | ||
| 149 | - | ### Parsing | |
| 150 | - | ||
| 151 | - | | Function | Signature | Description | | |
| 152 | - | |----------|-----------|-------------| | |
| 153 | - | | `parse_json` | `(json_str: String) -> Map` | Parse a JSON string into a Rhai map/array | | |
| 154 | - | | `parse_xml` | `(xml_str: String) -> Map` | Parse XML into a nested map with `name`, `attrs`, `text`, `children` keys | | |
| 155 | - | | `parse_feed` | `(input: String) -> Map` | Auto-detect RSS/Atom/JSON Feed and parse into a structured map with `title` and `entries` | | |
| 156 | - | ||
| 157 | - | `parse_feed` is the recommended function for standard feeds. It auto-detects the format (JSON Feed if input starts with `{`, otherwise XML) and extracts normalized fields: `title`, `link`, `id`, `summary`, `author`, `published` (as Unix timestamp), and `tags` for each entry. | |
| 158 | - | ||
| 159 | - | `parse_xml` is lower-level, useful when `parse_feed` does not cover a custom XML format. Each element becomes a map: `#{ name: "tag", attrs: #{ ... }, text: "...", children: [...] }`. | |
| 160 | - | ||
| 161 | - | ### Text | |
| 162 | - | ||
| 163 | - | | Function | Signature | Description | | |
| 164 | - | |----------|-----------|-------------| | |
| 165 | - | | `html_to_text` | `(html: String) -> String` | Strip HTML tags, render to plain text (80-char line width) | | |
| 166 | - | | `truncate` | `(text: String, max_len: Integer) -> String` | Truncate with ellipsis (`...`) if longer than `max_len` | | |
| 167 | - | | `str_contains` | `(text: String, pattern: String) -> Boolean` | Check if `text` contains `pattern` | | |
| 168 | - | | `str_split` | `(text: String, sep: String) -> Array` | Split string by separator, return array of strings | | |
| 169 | - | | `str_replace` | `(text: String, from: String, to: String) -> String` | Replace all occurrences of `from` with `to` | | |
| 170 | - | | `str_trim` | `(text: String) -> String` | Trim leading and trailing whitespace | | |
| 171 | - | ||
| 172 | - | ### Utilities | |
| 173 | - | ||
| 174 | - | | Function | Signature | Description | | |
| 175 | - | |----------|-----------|-------------| | |
| 176 | - | | `timestamp_now` | `() -> Integer` | Current UTC time as Unix epoch seconds | | |
| 177 | - | | `parse_datetime` | `(date_str: String) -> Integer` | Parse RFC 3339 or RFC 2822 date string to Unix timestamp | | |
| 178 | - | | `strip_tracking` | `(url: String) -> String` | Remove tracking query parameters (`utm_*`, `fbclid`, `gclid`, etc.) from a URL | | |
| 179 | - | | `parse_int` | `(text: String) -> Integer or ()` | Parse string to integer; returns `()` on failure | | |
| 180 | - | ||
| 181 | - | ### Debug | |
| 182 | - | ||
| 183 | - | | Function | Signature | Description | | |
| 184 | - | |----------|-----------|-------------| | |
| 185 | - | | `debug_print` | `(val: Dynamic) -> ()` | Print a value to the tracing debug log (visible in dev console) | | |
| 186 | - | ||
| 187 | - | ## Capabilities Declaration | |
| 188 | - | ||
| 189 | - | Plugins can optionally define a `capabilities()` function to advertise what they support. If omitted, all capabilities default to off and the fetch interval defaults to 900 seconds (15 minutes). | |
| 190 | - | ||
| 191 | - | ```rhai | |
| 192 | - | fn capabilities() { | |
| 193 | - | #{ | |
| 194 | - | supports_pagination: true, | |
| 195 | - | supports_date_filter: true, | |
| 196 | - | fetch_interval_secs: 600 | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ``` | |
| 200 | - | ||
| 201 | - | | Field | Type | Default | Description | | |
| 202 | - | |-------|------|---------|-------------| | |
| 203 | - | | `supports_pagination` | Boolean | `false` | Plugin returns `next_cursor` for paged fetches | | |
| 204 | - | | `supports_search` | Boolean | `false` | Plugin can filter by search query | | |
| 205 | - | | `supports_date_filter` | Boolean | `false` | Plugin can filter by date range | | |
| 206 | - | | `supports_read_state` | Boolean | `false` | Plugin tracks read/unread server-side | | |
| 207 | - | | `supports_starring` | Boolean | `false` | Plugin tracks starred/favorited server-side | | |
| 208 | - | | `requires_auth` | Boolean | `false` | Plugin needs authentication credentials | | |
| 209 | - | | `fetch_interval_secs` | Integer | `900` | Auto-fetch interval in seconds (0 disables auto-fetch) | | |
| 210 | - | ||
| 211 | - | ## Config Schema Field Types | |
| 212 | - | ||
| 213 | - | The `field_type` string in each schema field controls how the UI renders the input: | |
| 214 | - | ||
| 215 | - | | Type | Description | | |
| 216 | - | |------|-------------| | |
| 217 | - | | `text` | Single-line text input | | |
| 218 | - | | `textarea` | Multi-line text input | | |
| 219 | - | | `secret` | Masked password/API key input | | |
| 220 | - | | `url` | URL input | | |
| 221 | - | | `number` | Numeric input | | |
| 222 | - | | `toggle` | Boolean on/off switch | | |
| 223 | - | | `select` | Dropdown; requires an `options` array of strings | | |
| 224 | - | ||
| 225 | - | Each field in the `fields` array supports these properties: | |
| 226 | - | ||
| 227 | - | | Property | Type | Required | Description | | |
| 228 | - | |----------|------|----------|-------------| | |
| 229 | - | | `key` | String | Yes | Configuration key (used to access the value in `fetch`) | | |
| 230 | - | | `label` | String | Yes | Display label for the form | | |
| 231 | - | | `field_type` | String | Yes | One of the types above | | |
| 232 | - | | `required` | Boolean | No | Whether the field must be filled in (default: false) | | |
| 233 | - | | `description` | String | No | Help text shown below the field | | |
| 234 | - | | `default` | String | No | Default value | | |
| 235 | - | | `options` | Array | No | Options for `select` type | | |
| 236 | - | | `placeholder` | String | No | Placeholder text shown when empty | | |
| 237 | - | ||
| 238 | - | ## Safety Limits | |
| 239 | - | ||
| 240 | - | The Rhai engine enforces these limits per plugin execution: | |
| 241 | - | ||
| 242 | - | - **Max operations:** 100,000 -- Caps total operations per script call. A typical RSS fetch uses 1,000-5,000 operations. This limit catches infinite loops while allowing complex plugins. | |
| 243 | - | - **Max expression depth:** 128 -- Limits AST nesting depth for both expressions and function calls, preventing stack overflows from deeply recursive scripts. | |
| 244 | - | ||
| 245 | - | If a plugin exceeds either limit, the engine terminates execution and returns an error. | |
| 246 | - | ||
| 247 | - | ## Complete Minimal Example | |
| 248 | - | ||
| 249 | - | A bare-minimum plugin that fetches an RSS feed: | |
| 250 | - | ||
| 251 | - | ```rhai | |
| 252 | - | fn id() { | |
| 253 | - | "minimal-rss" | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | fn name() { | |
| 257 | - | "Minimal RSS" | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | fn config_schema() { | |
| 261 | - | #{ | |
| 262 | - | description: "A minimal RSS feed reader.", | |
| 263 | - | fields: [ | |
| 264 | - | #{ | |
| 265 | - | key: "feed_url", | |
| 266 | - | label: "Feed URL", | |
| 267 | - | field_type: "url", | |
| 268 | - | required: true | |
| 269 | - | } | |
| 270 | - | ] | |
| 271 | - | } | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | fn fetch(config, cursor) { | |
| 275 | - | let xml = http_get(config.feed_url); | |
| 276 | - | let feed = parse_feed(xml); | |
| 277 | - | let items = []; | |
| 278 | - | ||
| 279 | - | if feed.entries != () { | |
| 280 | - | for entry in feed.entries { | |
| 281 | - | let title = if entry.title != () { entry.title } else { "Untitled" }; | |
| 282 | - | let link = if entry.link != () { entry.link } else { "" }; | |
| 283 | - | let published = if entry.published != () { entry.published } else { timestamp_now() }; | |
| 284 | - | ||
| 285 | - | items.push(#{ | |
| 286 | - | id: #{ source: "minimal-rss", item_id: link }, | |
| 287 | - | bite: #{ | |
| 288 | - | author: "RSS", | |
| 289 | - | text: truncate(title, 100) | |
| 290 | - | }, | |
| 291 | - | content: #{ | |
| 292 | - | title: title, | |
| 293 | - | body: entry.summary, | |
| 294 | - | url: link | |
| 295 | - | }, | |
| 296 | - | meta: #{ | |
| 297 | - | source_name: "RSS", | |
| 298 | - | published_at: published, | |
| 299 | - | tags: [] | |
| 300 | - | } | |
| 301 | - | }); | |
| 302 | - | } | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | #{ items: items, has_more: false } | |
| 306 | - | } | |
| 307 | - | ``` | |
| 308 | - | ||
| 309 | - | ## Reference Plugins | |
| 310 | - | ||
| 311 | - | The built-in plugins serve as working examples: | |
| 312 | - | ||
| 313 | - | | Plugin | Lines | Demonstrates | | |
| 314 | - | |--------|-------|--------------| | |
| 315 | - | | `rss.rhai` | 148 | `parse_feed` for RSS/Atom, multi-URL support, tag extraction | | |
| 316 | - | | `hackernews.rhai` | 228 | `http_get_json` for API calls, pagination with cursors, score metadata | | |
| 317 | - | | `arxiv.rhai` | 179 | `parse_xml` for custom XML, `select` config field, category filtering | | |
| 318 | - | ||
| 319 | - | `rss.rhai` is the canonical example -- it covers the most common pattern (fetch XML, parse with `parse_feed`, map entries to items) in under 150 lines. | |
| 320 | - | ||
| 321 | - | ## Plugin File Location | |
| 322 | - | ||
| 323 | - | - **Development:** Place `.rhai` files in the `plugins/` directory at the project root. | |
| 324 | - | - **Production:** Place `.rhai` files in `<app_config_dir>/plugins/`. Bundled plugins are copied there automatically on first launch. |
| @@ -1,140 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast -- Audit History | |
| 2 | - | ||
| 3 | - | Full chronological audit log. See [audit_review.md](./audit_review.md) for current state. | |
| 4 | - | ||
| 5 | - | ## Changes Since Last Audit | |
| 6 | - | ||
| 7 | - | ### Eleventh audit (2026-03-28, Run 12 cross-project) | |
| 8 | - | - **Test count:** 602 (547 Rust + 55 JS). 0 clippy warnings. 0 failures. | |
| 9 | - | - **Grade:** A (maintained). v0.3.0. | |
| 10 | - | - **No code changes since Run 9.** | |
| 11 | - | - **New dependency advisories:** | |
| 12 | - | - rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki` | |
| 13 | - | - tar 0.4.44 x2 (RUSTSEC-2026-0067 symlink chmod, -0068 PAX size) — upgrade to 0.4.45 via `cargo update -p tar`. Via tauri-plugin-updater, relevant to OTA. | |
| 14 | - | - **Mandatory surprise:** None. Previous surprise (Rhai HTTP limits) fully resolved. | |
| 15 | - | - **No new code findings.** All previous items remain resolved. | |
| 16 | - | ||
| 17 | - | ### Rhai plugin aggregate timeout (2026-03-22) | |
| 18 | - | - **Test count:** 602 (547 Rust + 55 JS). 0 failures. | |
| 19 | - | - **Rhai Plugins Security:** B -> A. Added 60-second aggregate fetch timeout (`MAX_FETCH_DURATION`, `check_fetch_deadline()`, `AtomicU64` deadline). All 4 original Rhai HTTP security gaps now resolved (request limit, response cap, URL restriction, aggregate timeout). | |
| 20 | - | - **3 new tests:** fetch_deadline_zero_means_no_limit, fetch_deadline_future_passes, fetch_deadline_past_fails | |
| 21 | - | - **Cold spots:** 1 -> 0 (Rhai Plugins Security was the last remaining cold spot) | |
| 22 | - | ||
| 23 | - | ### Tenth audit (2026-03-18, Run 9 cross-project) | |
| 24 | - | - **Test count:** 599 (544 Rust + 55 JS). 0 clippy warnings. 0 failures. | |
| 25 | - | - **Grade:** A (maintained). v0.3.0. | |
| 26 | - | - **Release build:** macOS DMG signed+notarized, verified with codesign + spctl. | |
| 27 | - | - **No new findings.** All previous items remain resolved. | |
| 28 | - | - **Rhai HTTP limits:** Pre-existing gap (no request count limit, no response size cap on `http_get`/`http_get_json`). Accepted at A grade for pre-beta. Roadmap item. | |
| 29 | - | - **Mandatory surprise:** None. Previous surprise (Rhai HTTP limits) unchanged. | |
| 30 | - | ||
| 31 | - | ### Concurrency Upgrade (2026-03-13) | |
| 32 | - | - **Concurrency:** A- -> A | |
| 33 | - | - Replaced std::sync::RwLock/Mutex with parking_lot equivalents (eliminates poison risk). Removed LockPoisoned error variant. Fixed TOCTOU in create_feed with SQLite transaction. | |
| 34 | - | ||
| 35 | - | ### Observability Upgrade (2026-03-13) | |
| 36 | - | - **Observability:** B -> A | |
| 37 | - | - Added 61 `#[instrument(skip_all)]` annotations across 11 files | |
| 38 | - | - Coverage: all 39 Tauri commands (9 command files), 11 orchestrator methods (`bb-core/src/orchestrator.rs`), 11 sync service functions (`sync_service.rs`) | |
| 39 | - | - `use tracing::instrument;` import added to each file | |
| 40 | - | - `cargo check --workspace` passes clean | |
| 41 | - | ||
| 42 | - | ### Adversarial Test Audit (2026-03-13) | |
| 43 | - | ||
| 44 | - | Targeted adversarial testing phase focused on edge cases and boundary conditions. Test count: 520 -> 536 (+16 tests). All findings resolved: | |
| 45 | - | ||
| 46 | - | **Critical:** | |
| 47 | - | - **`truncate()` byte/char mismatch** — Used `text.len()` (bytes) instead of char count, causing incorrect truncation of CJK/emoji text. Fixed to use `.chars().count()` for proper Unicode handling. | |
| 48 | - | ||
| 49 | - | **High:** | |
| 50 | - | - **In-memory filters broke pagination** — `has_more` computed after filtering reduced item count; `sql_had_more` was true but filtered results had fewer items than page size. Fixed by checking `sql_had_more` before in-memory filtering. | |
| 51 | - | - **`parse_timestamp` silent failure** — Fell back to `Utc::now()` on parse errors, causing corrupted items to appear at top of feeds. Fixed to use `DateTime::UNIX_EPOCH` (Jan 1, 1970) as fallback. | |
| 52 | - | - **`feed_tags` sync delete broke on colons** — Tag names containing colons broke delete parsing. Fixed to use UUID length (36 chars) for parsing instead of colon delimiter. | |
| 53 | - | - **RSS items without guid or link collided** — Items lacking both fields got empty string as ID, causing collisions. Fixed to synthesize deterministic hash ID from title+summary+published. | |
| 54 | - | ||
| 55 | - | All fixes include regression tests. Zero new clippy warnings. Clean production code unwrap count maintained (7 total, all startup/static). | |
| 56 | - | ||
| 57 | - | ### Seventh audit (2026-03-16, Run 6 cross-project) | |
| 58 | - | - **Test count:** 536 -> 599 (544 Rust + 55 JS) | |
| 59 | - | - **Grade:** A (maintained). | |
| 60 | - | - **New finding (MEDIUM):** 111 `.unwrap()` calls in `bb-core/src/rhai_plugin/conversions.rs` — production Rhai Dynamic-to-Rust conversion that could crash the app if a plugin returns unexpected types. Should use `.try_cast()` or error returns. | |
| 61 | - | - **Mandatory surprise:** conversions.rs unwraps — Genuine issue. The rest of BB has only 7 production unwraps (all startup/static). | |
| 62 | - | - **Previous items verified:** All previous remediated items confirmed intact. | |
| 63 | - | ||
| 64 | - | ### Sixth audit (2026-03-13) -- pre-launch skeptical lens | |
| 65 | - | ||
| 66 | - | Fresh audit. 520 tests verified via `cargo test --workspace -- --list`. Clippy clean. 7 unwrap/expect in non-test production code (all safe: startup, static regex, graceful fallbacks). | |
| 67 | - | ||
| 68 | - | New findings: | |
| 69 | - | 1. **sync_disconnect is a no-op** — returns Ok(true) without disconnecting. Sync session remains active, scheduler keeps running. | |
| 70 | - | 2. **OPML import doesn't validate URL schemes** — unlike feed creation which checks http(s)://, OPML import stores raw xmlUrl values. Caught at fetch time by Rhai layer but bad URLs persist in DB. | |
| 71 | - | 3. **HTML sanitizer missing `<base>` tag** — add to DANGEROUS_ELEMENTS in utils.js. | |
| 72 | - | ||
| 73 | - | Documentation upgraded: QueryCondition fields documented with valid values, 19 error variants documented across 3 enums, stale fetch_plugin doc fixed, RhaiPlugin + ReaderResult field docs added, architecture.md created, README features section added. | |
| 74 | - | ||
| 75 | - | ### Fifth audit (2026-03-11) -- full fresh audit | |
| 76 | - | ||
| 77 | - | Fresh audit of entire codebase per audit.md. Test count: 359 (was 320). Clippy clean (0 warnings). 4 unwrap/expect in non-test production code (all startup/static regex -- acceptable). | |
| 78 | - | ||
| 79 | - | ### Growth since fourth audit | |
| 80 | - | ||
| 81 | - | - **Rust source LOC**: ~5,400 -> ~7,600 (+2,200 lines). Primary additions: sync_service.rs (833 lines), sync_scheduler.rs, settings-sync commands, migration 007. | |
| 82 | - | - **JS LOC**: 1,658 -> 2,140 (+482 lines). Primary addition: settings-sync.js (400 lines). | |
| 83 | - | - **Test count**: 320 -> 359 (+39 tests). 15 sync tests, additional integration tests. | |
| 84 | - | - **External deps**: unchanged at 26 (synckit-client is a workspace path dependency). | |
| 85 | - | ||
| 86 | - | ### New findings (this audit) | |
| 87 | - | ||
| 88 | - | 1. **Rhai HTTP host functions with no limits** -- no request count cap, no response size cap, no URL scheme restriction, no aggregate timeout. Genuine security gap. (Mandatory surprise) | |
| 89 | - | 2. **No circuit breaker** -- consecutive_failures tracked but no auto-disable threshold. | |
| 90 | - | 3. **Sync changelog unbounded growth** -- no retention cap when sync is disconnected. | |
| 91 | - | 4. **FTS query injection edge cases** -- NEAR, column: prefix not covered by sanitize_fts_query. | |
| 92 | - | 5. **Zero JS tests** -- persists from prior audits, now covering 2,140 lines across 12 files. | |
| 93 | - | 6. **Sync polling UX uncertainty** -- OAuth callback polling has comment trail suggesting unreliable flow. | |
| 94 | - | 7. **HN plugin N+1** -- 31+ HTTP calls per fetch, no parallelism within Rhai. | |
| 95 | - | ||
| 96 | - | ### Grades changed from fourth audit | |
| 97 | - | ||
| 98 | - | - Code Quality: A -> A (unchanged; clean clippy maintained) | |
| 99 | - | - Testing: A- (unchanged; +39 tests but JS testing gap persists) | |
| 100 | - | - Performance: A- (unchanged) | |
| 101 | - | - Documentation: A- -> B+ (architecture overview doc still missing; plugin authoring guide exists but not linked from app) | |
| 102 | - | - Frontend: A- -> B+ (2,140 lines now, still zero tests; sync UI adds complexity) | |
| 103 | - | - Observability: new dimension, graded B (tracing configured but no structured spans) | |
| 104 | - | - Concurrency: new dimension, graded A- (tokio async, AbortHandles, sync backoff) | |
| 105 | - | - Resilience: new dimension, graded A- (health tracking, stale cleanup, but no circuit breaker) | |
| 106 | - | - Type Safety: A (unchanged; newtypes solid) | |
| 107 | - | - API Consistency: A (unchanged) | |
| 108 | - | - Codebase Size: A (unchanged; growth is proportional to new features) | |
| 109 | - | ||
| 110 | - | ### Verification | |
| 111 | - | ||
| 112 | - | - `cargo clippy --workspace`: PASS (0 warnings) | |
| 113 | - | - `cargo test --workspace`: PASS (359 tests, 0 failures) | |
| 114 | - | - Zero `.unwrap()` in production business logic | |
| 115 | - | - 4 `.expect()` in production code (2 startup, 1 entry point, 1 static regex -- all acceptable) | |
| 116 | - | ||
| 117 | - | ### JS Audit Remediation (2026-03-11) -- Complete (8/8) | |
| 118 | - | ||
| 119 | - | All JS audit findings resolved: | |
| 120 | - | - **Critical (1):** escapeAttr() on item.id in onclick handler | |
| 121 | - | - **Medium (4):** Stale OAuth polling loop removed, updateReadState/updateStarState deduplicated, escapeHtml() on timeAgo/score, detail.js state desync fixed (onItemsChanged subscriber) | |
| 122 | - | - **Low (3):** Unused variables removed (pendingAuth, total), prompt() replaced with BB.ui.openFormModal() for tag editing, inline styles replaced with 6 CSS classes | |
| 123 | - | ||
| 124 | - | ### Audit Grade Corrections (2026-03-13) | |
| 125 | - | ||
| 126 | - | Corrected stale grades where the auditor missed existing code: | |
| 127 | - | - **Resilience:** A- -> A. Circuit breaker implemented (migration 008, `CIRCUIT_BREAKER_THRESHOLD=10`, skip in auto-fetch, reset API, event emission). Changelog cap at 10,000 entries with `enforce_changelog_retention()` running on sync tick. | |
| 128 | - | - **Documentation:** A- -> A. architecture.md (211 lines), plugin_authoring.md (324 lines), README (74 lines) all complete. | |
| 129 | - | - **Frontend:** B+ -> A-. JS test infrastructure added (28 tests covering state, utils, sources, items modules). | |
| 130 | - | - **Overall:** A- -> A. All dimensions now A- or above. | |
| 131 | - | ||
| 132 | - | ### Security Deep Dive (2026-03-13) -- Complete (2/2) | |
| 133 | - | ||
| 134 | - | - **HTML sanitization:** Added `ammonia = "4"` to `bb-core/Cargo.toml`; `orchestrator.rs` applies `ammonia::clean()` on feed content body after URL tracking stripping and before DB upsert, preventing XSS from untrusted feed sources | |
| 135 | - | - **Search query length limit:** `repository.rs` — `MAX_SEARCH_QUERY_LENGTH = 500` constant with early return in `list_search()` on overlong queries, preventing FTS5 DoS | |
| 136 | - | ||
| 137 | - | ### Still open (from prior audits) | |
| 138 | - | ||
| 139 | - | - ~~Add JS tests (state.js, search debounce, keyboard navigation, sync settings)~~ -- 55 JS tests added | |
| 140 | - | - Consider parallel plugin fetching for scale |
| @@ -1,175 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast -- Code Audit Review | |
| 2 | - | ||
| 3 | - | **Last audited:** 2026-04-18 (thirteenth audit, Run 15 cross-project) | |
| 4 | - | **Previous audit:** 2026-04-15 (twelfth audit, Run 14 cross-project) | |
| 5 | - | ||
| 6 | - | ## Overall Grade: A | |
| 7 | - | ||
| 8 | - | Run 15 cross-project audit (updated 2026-04-22). 601 tests (all pass, `--workspace`). 0 clippy warnings. v0.3.1. ~23,458 LOC. Test "regression" was false (audit ran without `--workspace`). bb-feed has 110 tests (not 0). FTS sanitization is correct (quotes all terms). XSS: all user content properly escaped. Observability expanded to 240 instrument annotations. | |
| 9 | - | ||
| 10 | - | ## Scorecard | |
| 11 | - | ||
| 12 | - | | Dimension | Grade | Notes | | |
| 13 | - | |-----------|:-----:|-------| | |
| 14 | - | | Code Quality | A- | Near-zero unwraps in production code. Clean clippy. Consistent error handling. | | |
| 15 | - | | Architecture | A | Excellent 4-crate separation: interface <- core <- db, feed. Tauri layer is a thin shell. Sync service well-isolated. | | |
| 16 | - | | Testing | A | 601 tests (all pass, `--workspace`). bb-feed: 110 tests. Coverage across all layers maintained. | | |
| 17 | - | | Security | A | AES-256-GCM encryption at rest, Rhai sandboxing, input validation, PKCE OAuth2, path traversal protection. FTS queries fully sanitized (all terms quoted). All user content escaped in frontend. | | |
| 18 | - | | Performance | A- | Proper indexes, FTS5, pagination, debounced search, theme caching. All db/feed functions instrumented for tracing. | | |
| 19 | - | | Documentation | A- | architecture.md, plugin_authoring.md, database_schema.md, frontend_architecture.md, troubleshooting.md. All public items documented with `///`. | | |
| 20 | - | | Dependencies | A | 26 direct deps, reasonable for Tauri app. Workspace-level dep management. | | |
| 21 | - | | Frontend | A- | Good UX patterns (skeleton loading, undo, keyboard shortcuts, themes). Vanilla JS. All user content escaped with escapeHtml()/escapeAttr(). | | |
| 22 | - | | Type Safety | B | Newtype UUIDs via macro, typed errors, exhaustive enums. Some stringly-typed paths remain. | | |
| 23 | - | | Observability | A | 240 instrument annotations (src-tauri 66, crates 174). Full tracing coverage across all layers. | | |
| 24 | - | | Concurrency | A- | Tokio async, parking_lot RwLock/Mutex, AbortHandle-based task cancellation, exponential backoff. | | |
| 25 | - | | Resilience | B+ | Per-feed error isolation, health indicators, stale item cleanup, sync retry with backoff. Circuit breaker implemented. | | |
| 26 | - | | API Consistency | A | Uniform command patterns, consistent error serialization, builder patterns, unified pagination. | | |
| 27 | - | | Migration Safety | A- | SQLite migrations, all additive. | | |
| 28 | - | | Codebase Size | A | ~23,458 LOC for 20+ features. Right-sized for its scope. | | |
| 29 | - | ||
| 30 | - | ## Module Heatmap | |
| 31 | - | ||
| 32 | - | | Module | Code | Arch | Test | Security | Perf | Docs | Deps | Frontend | | |
| 33 | - | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:----:|:--------:| | |
| 34 | - | | bb-interface (~380 lines) | A | A | A | n/a | n/a | A | A | n/a | | |
| 35 | - | | bb-core (~1,700 lines) | A | A | B | A | A- | B+ | n/a | n/a | | |
| 36 | - | | bb-db (~1,100 lines) | A | A | B | A | B+ | B+ | n/a | n/a | | |
| 37 | - | | bb-feed (~500 lines) | A | A- | A | n/a | A- | B | n/a | n/a | | |
| 38 | - | | src-tauri (~3,900 lines) | A- | A | B- | B+ | A | B+ | n/a | n/a | | |
| 39 | - | | JS Frontend (2,140 lines) | A- | B+ | B | B | B+ | B | n/a | A | | |
| 40 | - | | Rhai Plugins (~580 lines) | B+ | A- | A | A | n/a | A- | n/a | n/a | | |
| 41 | - | | SQL Migrations (250 lines) | A | A- | n/a | n/a | A | A- | n/a | n/a | | |
| 42 | - | ||
| 43 | - | ### Cold Spots (all resolved or incorrect, verified 2026-04-22) | |
| 44 | - | ||
| 45 | - | 1. ~~**bb-feed: 0 tests**~~ -- Incorrect finding. bb-feed has 110 tests. Audit ran without `--workspace`. | |
| 46 | - | 2. ~~**Manual XSS risks**~~ -- Incorrect finding. All user content escaped with escapeHtml()/escapeAttr(). Rich HTML uses sanitizeHtml(). | |
| 47 | - | 3. ~~**db/feed not instrumented**~~ -- Fixed 2026-04-22. 174 instrument annotations added to crates. | |
| 48 | - | 4. ~~**FTS query injection**~~ -- Incorrect finding. sanitize_fts_query quotes all terms (neutralizes NEAR, column: prefix). Tests confirm at lines 1862-1874. | |
| 49 | - | ||
| 50 | - | ## Mandatory Surprise | |
| 51 | - | ||
| 52 | - | **Keychain migration race condition (crypto.rs:74-85).** | |
| 53 | - | ||
| 54 | - | The keychain migration path reads the old encryption key from disk, stores it in the OS keychain, then deletes the disk file. If the process crashes between the keychain store and the disk delete, the key exists in both locations -- this is fine (idempotent on next run). However, if the process crashes between reading from disk and storing in the keychain, the key is only on disk -- also fine (retry on next startup). | |
| 55 | - | ||
| 56 | - | The actual race condition is: if two instances of the app start simultaneously during migration, both read the disk key, both try to store in the keychain, and one may overwrite the other's store. Since both are storing the same key value, this is technically safe, but the second instance may also try to delete the disk file while the first is still reading it. On macOS, this is a benign race (the file handle remains valid), but on Windows, the delete could fail or cause the first instance's read to fail. | |
| 57 | - | ||
| 58 | - | **Verdict:** Low practical risk -- simultaneous app launches during first-time migration is unlikely. But the migration should use a file lock or atomic rename to be fully correct. | |
| 59 | - | ||
| 60 | - | ### Previous Surprise | |
| 61 | - | ||
| 62 | - | **Rhai plugin runtime HTTP limits** -- all 4 gaps resolved (request count limit, response size cap, URL restriction, aggregate fetch timeout). Verdict: Resolved. | |
| 63 | - | ||
| 64 | - | ## Strengths | |
| 65 | - | ||
| 66 | - | - **Zero `.unwrap()` in business logic.** Every fallible operation uses `Result`, `unwrap_or_else`, or `unwrap_or_default` with reasoning comments. Only 4 unwrap/expect in startup/static contexts. | |
| 67 | - | ||
| 68 | - | - **All SQL is parameterized.** Every query in `repository.rs` uses `?N` placeholders with `.bind()`. Dynamic placeholder strings built safely. | |
| 69 | - | ||
| 70 | - | - **Defense-in-depth security.** Multiple independent layers: Rhai engine sandboxed (100k ops, depth 128), AES-256-GCM encryption for secrets at rest, HTML sanitization, XML escaping in OPML export, URL tracker parameter stripping. | |
| 71 | - | ||
| 72 | - | - **Clean crate boundaries.** `bb-interface` defines types/traits with zero impl deps. `bb-db` handles persistence with no plugin knowledge. `bb-core` orchestrates without touching SQL. No circular dependencies. | |
| 73 | - | ||
| 74 | - | - **Well-designed sync integration.** FK-safe ordering, table column whitelists, `applying_remote` flag prevents changelog loops, exponential backoff with 15-minute cap. | |
| 75 | - | ||
| 76 | - | ## Weaknesses | |
| 77 | - | ||
| 78 | - | ### 1. ~~Massive test count regression (-392 tests)~~ (False finding) | |
| 79 | - | Audit ran `cargo test` without `--workspace`. BB uses `default-members = ["src-tauri"]`, so only 210 of 601 tests were counted. Verified 2026-04-22. | |
| 80 | - | ||
| 81 | - | ### 2. ~~bb-feed has 0 tests~~ (False finding) | |
| 82 | - | bb-feed has 110 tests. Same `--workspace` issue. Verified 2026-04-22. | |
| 83 | - | ||
| 84 | - | ### 3. ~~Manual XSS risks~~ (False finding) | |
| 85 | - | All user content (feed titles, authors, descriptions, tags) escaped with escapeHtml()/escapeAttr(). Rich HTML body uses sanitizeHtml(). Verified 2026-04-22. | |
| 86 | - | ||
| 87 | - | ### 4. ~~FTS query injection~~ (False finding) | |
| 88 | - | sanitize_fts_query wraps every word in double quotes, which neutralizes all FTS5 operators including NEAR and column: prefixes. Tests confirm this at lines 1862-1874 of repository.rs. Verified 2026-04-22. | |
| 89 | - | ||
| 90 | - | ### 5. ~~Sparse documentation~~ (Fixed) | |
| 91 | - | All public items now have `///` doc comments. 14 doc files in docs/. Fixed 2026-04-22. | |
| 92 | - | ||
| 93 | - | ## Competitive Comparison | |
| 94 | - | ||
| 95 | - | Based on `docs/apps/bb/competition.md`, BB holds a unique position as the only native desktop feed reader with a user-scriptable plugin system. | |
| 96 | - | ||
| 97 | - | **Gaps closed since the competition analysis was written:** | |
| 98 | - | - Full-text search, Tags/categories, URL tracker stripping, JSON Feed format, Feed config validation, Secret encryption, Feed health monitoring, Stale item cleanup, Theming, Cloud sync | |
| 99 | - | ||
| 100 | - | **Remaining competitive gaps:** | |
| 101 | - | 1. Reader view / full-article fetch -- planned as a plugin (Phase 5) | |
| 102 | - | 2. Filter/query feeds (virtual feeds from rules) -- Phase 5 | |
| 103 | - | 3. Mobile support -- deferred (Tauri mobile) | |
| 104 | - | ||
| 105 | - | ## Action Items | |
| 106 | - | ||
| 107 | - | ### Run 15 (2026-04-18, corrected 2026-04-22) | |
| 108 | - | 1. ~~**[CRITICAL]** Investigate test regression~~ -- False finding. 601 tests with `--workspace`. | |
| 109 | - | 2. ~~**[HIGH]** Add tests to bb-feed~~ -- False finding. bb-feed has 110 tests. | |
| 110 | - | 3. ~~**[MEDIUM]** Audit and fix manual XSS gaps~~ -- False finding. All content properly escaped. | |
| 111 | - | 4. ~~**[MEDIUM]** Add tracing instrumentation to db/feed modules~~ -- Done (174 annotations added). | |
| 112 | - | 5. ~~**[LOW]** Harden FTS query sanitization~~ -- False finding. Already secure (terms quoted). | |
| 113 | - | 6. ~~**[LOW]** Improve documentation coverage~~ -- Done. All public items documented. | |
| 114 | - | ||
| 115 | - | ### Previous action items | |
| 116 | - | - ~~Circuit breaker~~ -- FIXED (migration 008, threshold 10, skip in auto-fetch, reset API) | |
| 117 | - | - Changelog maintenance -- PARTIALLY FIXED | |
| 118 | - | - ~~FTS injection~~ -- RESOLVED (was already secure, terms quoted) | |
| 119 | - | ||
| 120 | - | ### Resolved (prior audits) | |
| 121 | - | - ~~Fix single-quote XSS in tag filter~~ -- sources.js uses addEventListener | |
| 122 | - | - ~~Set 0600 permissions on encryption.key~~ -- crypto.rs sets 0o600 | |
| 123 | - | - ~~Replace `.expect("poisoned")`~~ -- replaced with error propagation | |
| 124 | - | - ~~Remove deprecated health stubs~~ -- removed | |
| 125 | - | - ~~Add `data:` URI blocking~~ -- utils.js blocks both javascript: and data: | |
| 126 | - | - ~~bb-feed generator tests~~ -- tag filtering tests added | |
| 127 | - | - ~~Tauri command integration tests~~ -- 50 tests added | |
| 128 | - | - ~~Plugin authoring guide~~ -- included in README | |
| 129 | - | - ~~Fix clippy violations~~ -- all resolved | |
| 130 | - | - ~~Add repository doc comments~~ -- 46+ methods documented | |
| 131 | - | - ~~Unify pagination strategy~~ -- standardized page_size+1 | |
| 132 | - | - ~~Entity ID newtypes~~ -- FeedId, ItemId, BusserStateId, BusserId | |
| 133 | - | - ~~ApiErrorCode enum~~ -- replaces String error codes | |
| 134 | - | ||
| 135 | - | ## Metrics Over Time | |
| 136 | - | ||
| 137 | - | | Metric | Audit 1 (02-27) | Audit 2 (02-28) | Audit 3 (02-28) | Audit 4 (03-01) | Audit 5 (03-11) | Audit 6 (03-13) | Adversarial (03-13) | 03-22 | Run 12 (03-28) | Run 14 (04-15) | Run 15 (04-18) | Run 15 corrected (04-22) | | |
| 138 | - | |--------|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:| | |
| 139 | - | | Overall Grade | A | A- | A- | A- | A- | A- | A- | A | A | A | B+ | A | | |
| 140 | - | | Tests | 178 | 225 | 225 | 315 | 359 | 520 | 536 | 599 | 602 | 602 | 210 | 601 | | |
| 141 | - | | Clippy warnings | 0 | 0 | 8 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | | |
| 142 | - | | Unwrap/expect (prod) | 6 | 3 | 3 | 3 | 4 | 7 | 7 | 7+111 | 7+111 | 7+111 | 7+111 | 7+111 | | |
| 143 | - | | Rust source LOC | ~5,400 | ~5,400 | ~5,400 | ~5,400 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~23,458 | ~23,458 | | |
| 144 | - | | JS LOC | 1,658 | 1,658 | 1,658 | 1,658 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | | |
| 145 | - | | Cold spots | 5 | 5 | 2 | 8 | 5 | 4 | 4 | 1 | 0 | 0 | 4 | 0 | | |
| 146 | - | ||
| 147 | - | --- | |
| 148 | - | ||
| 149 | - | See [audit_history.md](./audit_history.md) for full chronological audit log. | |
| 150 | - | ||
| 151 | - | --- | |
| 152 | - | ||
| 153 | - | ## Documentation Review | |
| 154 | - | ||
| 155 | - | **Last reviewed:** 2026-03-04 (first doc audit) | |
| 156 | - | ||
| 157 | - | ### Overall Doc Grade: B- | |
| 158 | - | ||
| 159 | - | Doc set exists but is sparse in some areas. Changelog only partially maintained. Module docs need expansion. | |
| 160 | - | ||
| 161 | - | ### Document Heatmap | |
| 162 | - | ||
| 163 | - | | Document | Status | Last Verified | Notes | | |
| 164 | - | |----------|:------:|:-------------:|-------| | |
| 165 | - | | docs/apps/bb/todo.md | Current | 2026-04-18 | Active task list | | |
| 166 | - | | docs/apps/bb/description.md | Placeholder | 2026-03-04 | Intentional placeholder | | |
| 167 | - | | docs/apps/bb/competition.md | Current | 2026-03-04 | Competitive analysis | | |
| 168 | - | | docs/apps/bb/structural_metrics.md | Stale | 2026-03-11 | Needs update for current LOC/test counts | | |
| 169 | - | | docs/apps/bb/stress_test.md | Current | 2026-03-11 | Phase 4/5 stress test | | |
| 170 | - | | README.md | Current | 2026-03-04 | Setup instructions | | |
| 171 | - | ||
| 172 | - | ### Doc Action Items | |
| 173 | - | ||
| 174 | - | - Update structural_metrics.md for current LOC/test counts | |
| 175 | - | - Expand module-level documentation |
| @@ -1,64 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast AI Anti-Pattern Cleanup | |
| 2 | - | ||
| 3 | - | ## Summary | |
| 4 | - | ||
| 5 | - | Auditing Balanced Breakfast (Rust/Tauri 2 desktop app, Rhai plugin engine) for AI-induced anti-patterns. Codebase is clean — zero HIGH findings, no dead code (`#[allow(dead_code)]` count: 0), no stubs (`todo!`/`unimplemented!` count: 0), no string-typing. Five MEDIUM, one LOW. | |
| 6 | - | ||
| 7 | - | ## Fixes (MEDIUM — all five) | |
| 8 | - | ||
| 9 | - | ### M1. Silent API key file write failure | |
| 10 | - | `src-tauri/src/state.rs:172` — `let _ = std::fs::write(&key_path, api_key)` in `save_api_key()`. If the write fails (permissions, disk full), the user thinks their API key was saved but it wasn't. Next launch, sync won't work and they'll have no idea why. | |
| 11 | - | ||
| 12 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::error!`. | |
| 13 | - | ||
| 14 | - | ### M2. Silent plugins directory creation failure | |
| 15 | - | `src-tauri/src/state.rs:222` — `let _ = std::fs::create_dir_all(plugins_dir)` during bundled plugin copy. If this fails, the subsequent file copies will also fail silently — no plugins get installed on first launch, user sees an empty source list with no explanation. | |
| 16 | - | ||
| 17 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!` and early return from the function. | |
| 18 | - | ||
| 19 | - | ### M3. Silent sync interval + last_sync parse fallbacks | |
| 20 | - | `src-tauri/src/sync_scheduler.rs:65-74` — Two `.ok()` chains: `sync_interval_minutes` query + parse both silently fall back to 15, and `last_sync_at` query silently falls back to empty string. User's configured sync interval is ignored without any trace. | |
| 21 | - | ||
| 22 | - | **Fix:** Replace `.ok()` chains with explicit match + `tracing::warn!` on failures. Keep the default values. | |
| 23 | - | ||
| 24 | - | ### M4. Silent cleanup_changelog failure after manual sync | |
| 25 | - | `src-tauri/src/commands/sync.rs:360` — `let _ = sync_service::cleanup_changelog(pool).await` after `sync_now`. If cleanup fails, old changelog entries accumulate unboundedly. The retention cap in the scheduler mitigates this, but a manual-sync-only user (auto_sync disabled) won't hit the scheduler path. | |
| 26 | - | ||
| 27 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!`. | |
| 28 | - | ||
| 29 | - | ### M5. Silent encryption key file deletion after keychain migration | |
| 30 | - | `crates/bb-core/src/crypto.rs:80` — `let _ = std::fs::remove_file(file_path)` after migrating the encryption key to keychain. If deletion fails, the key persists on disk in addition to the keychain. Not a correctness issue, but violates the security invariant (key should only exist in keychain post-migration). | |
| 31 | - | ||
| 32 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!`. | |
| 33 | - | ||
| 34 | - | ## Fixes (LOW — one) | |
| 35 | - | ||
| 36 | - | ### L1. Silent timestamp parse fallback in push_changes | |
| 37 | - | `src-tauri/src/sync_service.rs:131-132` — `.unwrap_or_else(|_| Utc::now())` when parsing a changelog timestamp. If the stored timestamp is malformed, the change silently gets a `now` timestamp, which can cause ordering issues during sync conflict resolution. | |
| 38 | - | ||
| 39 | - | **Fix:** Add `tracing::warn!` inside the `unwrap_or_else` closure including the raw timestamp string. | |
| 40 | - | ||
| 41 | - | ## Skipping (intentional design) | |
| 42 | - | ||
| 43 | - | - **`let _ = app.emit(...)` (3 instances: sync_scheduler:121, commands/sync:248,356, commands/feeds:509)** — fire-and-forget UI event emission. If the window is closed during shutdown, these fail, which is expected. Same pattern as channel sends. | |
| 44 | - | - **`let _ = stream.write_all` / `let _ = stream.flush()` in callback_server (commands/sync:141,142,151)** — fire-and-forget HTTP response on localhost OAuth callback. Logging would be noise. | |
| 45 | - | - **`let _ = app.emit("update-available", ...)` in lib.rs:126** — OTA update notification, best-effort. Already inside a match arm with `Ok(Some(update))`. | |
| 46 | - | - **`session.logout().await.ok()` pattern** — not present in BB (no IMAP). | |
| 47 | - | - **`update.body.unwrap_or_default()` in lib.rs:130** — OTA update body is optional, empty default is correct (Option field on the update struct). | |
| 48 | - | - **`.unwrap_or_default()` on `serde_json::to_string` (feeds.rs:197,242)** — serializing a `serde_json::Value` back to string; this cannot fail for valid JSON values (the JSON was just parsed from the DB). The default (`""`) is unreachable. | |
| 49 | - | - **`sync_service::get_sync_state(pool, "initial_snapshot_done").unwrap_or_default()` (sync_scheduler:94, sync.rs:344)** — empty default means "not done", which triggers snapshot creation. Correct fallback behavior. | |
| 50 | - | - **`count_pending_changes(pool).await.unwrap_or(0)` in sync status (sync.rs:242)** — display-only field in the status response. 0 is a safe fallback for a UI counter. | |
| 51 | - | - **`cursor_str.parse().unwrap_or(0)` in pull_changes (sync_service.rs:179)** — empty string from missing key correctly parses to default cursor 0. Starting from cursor 0 means "pull everything from the beginning" which is the correct recovery behavior. | |
| 52 | - | - **`data.and_then(|d| serde_json::from_str(&d).ok())` in push_changes (sync_service.rs:134)** — if data fails to parse, the change entry is filtered out via `filter_map`. The entry was already logged by the trigger. Skipping one malformed changelog entry is preferable to failing the entire push batch. | |
| 53 | - | - **`.ok()` chains in sync_status command (sync.rs:206,221,231,237)** — all are display-only fields for the sync settings UI. Returning `None`/`false`/`15` defaults for unreadable settings is correct for a status response. | |
| 54 | - | - **`json_to_i32` returning 0 for unexpected types (sync_service.rs:353-358)** — defensive JSON number extraction for is_read/is_starred. 0 means unread/unstarred which is the safe default for unknown values. | |
| 55 | - | - **`data["id"].as_str().unwrap_or("")` in apply_upsert feed_items (sync_service.rs:296)** — if `id` is missing from the JSON, the UPDATE WHERE id = '' matches zero rows (no-op). Correct behavior for malformed data. | |
| 56 | - | - **All `let _ =` in `crates/` test code** — test cleanup (remove_dir_all, create_dir_all). Best-effort and appropriate for tests. | |
| 57 | - | - **`.unwrap_or_default()` on Rhai Dynamic value conversions (rhai_plugin/conversions.rs)** — Rhai values are dynamically typed; converting to empty string/vec when the type doesn't match is the correct plugin behavior (plugins can return any Rhai type). | |
| 58 | - | - **`std::env::var(...).ok()` for optional env vars (state.rs:166, sync_scheduler implied)** — `BB_SYNC_API_KEY`, `BB_SYNC_SERVER_URL` etc. Optional config, absence is normal. | |
| 59 | - | - **`.unwrap_or_else(|_| PathBuf::from("."))` in find_plugins_dir (state.rs:208)** — last-resort fallback when both app_config_dir and app_data_dir fail. Using current directory is reasonable. | |
| 60 | - | - **`std::fs::read_dir(plugins_dir).map(|mut d| d.next().is_some()).unwrap_or(false)` in copy_bundled_plugins (state.rs:214)** — checking if directory has contents, false default means "copy plugins", which is the safe path. | |
| 61 | - | - **`stream.read(&mut buf).unwrap_or(0)` in callback_server (sync.rs:123)** — read failure means 0 bytes read, request string will be empty, no code found, loop continues waiting. Correct. | |
| 62 | - | ||
| 63 | - | ## Verification | |
| 64 | - | - `cd ~/Code/Apps/balanced_breakfast && cargo check && cargo test` |
| @@ -1,109 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast -- Competitive Analysis | |
| 2 | - | ||
| 3 | - | Last updated: 2026-04-02 | |
| 4 | - | ||
| 5 | - | ## Positioning | |
| 6 | - | ||
| 7 | - | Balanced Breakfast is the only native desktop feed aggregator with a user-scriptable plugin system. While RSS readers are a mature category, no competitor offers Rhai scripting for custom source integration. BB unifies RSS, Hacker News, arXiv, GitHub Trending, and 7 other sources into a single timeline -- offline-first, free, and source-available. | |
| 8 | - | ||
| 9 | - | The core advantage is extensibility: any developer can write a plugin for any data source in under 100 lines of Rhai. Combined with E2E encrypted cloud sync, auto-fetch scheduling with circuit breakers, and a native desktop experience, BB occupies a unique position between simple RSS readers and complex self-hosted aggregators. | |
| 10 | - | ||
| 11 | - | ## Pricing Comparison | |
| 12 | - | ||
| 13 | - | | App | Price | Model | | |
| 14 | - | |-----|-------|-------| | |
| 15 | - | | **Balanced Breakfast** | Free | Source-available (PolyForm NC) | | |
| 16 | - | | Feedly | $0-$18/mo | Freemium (100 sources free, AI features paid) | | |
| 17 | - | | Inoreader | $0-$10/mo | Freemium (150 sources free, rules paid) | | |
| 18 | - | | NetNewsWire | Free | Open source (MIT) | | |
| 19 | - | | Reeder | $0 (classic) / subscription | Freemium | | |
| 20 | - | | Miniflux | $15/yr (hosted) or self-host | Open source (Apache 2.0) | | |
| 21 | - | | FreshRSS | Free (self-host) | Open source (AGPL) | | |
| 22 | - | ||
| 23 | - | ## Feature Matrix | |
| 24 | - | ||
| 25 | - | | Feature | BB | Feedly | NNW | Inoreader | Reeder | Miniflux | FreshRSS | | |
| 26 | - | |---------|:--:|:------:|:---:|:---------:|:------:|:--------:|:--------:| | |
| 27 | - | | RSS/Atom/JSON Feed | Y | Y | Y | Y | Y | Y | Y | | |
| 28 | - | | Non-RSS sources (HN, arXiv, etc.) | Y | N | N | N | N | N | N | | |
| 29 | - | | User-scriptable plugins | Y | N | N | N | N | N | N | | |
| 30 | - | | Desktop native | Y | N | Y* | N | Y* | N | N | | |
| 31 | - | | Windows + Linux + macOS | Y | N | N** | N | N** | N | N | | |
| 32 | - | | Offline-first | Y | N | Y | N | Y | N | N | | |
| 33 | - | | Full-text search | Y | Y | Y | Y | Y | Y | Y | | |
| 34 | - | | E2E encrypted sync | Y | N | N | N | N | N | N | | |
| 35 | - | | Self-hostable | N | N | N | N | N | Y | Y | | |
| 36 | - | | AI features | N | Y | N | N | N | N | N | | |
| 37 | - | | Reader view | Y | Y | N | Y | Y | N | N | | |
| 38 | - | | Feed health tracking | Y | N | N | N | N | N | N | | |
| 39 | - | | Auto-fetch with circuit breaker | Y | Y | N | Y | N | Y | Y | | |
| 40 | - | | URL tracker stripping | Y | N | N | N | N | N | N | | |
| 41 | - | | Tags | Y | Y | N | Y | N | N | Y | | |
| 42 | - | | Query feeds (saved filters) | Y | N | N | Y | N | N | N | | |
| 43 | - | ||
| 44 | - | \* macOS only. \*\* Apple ecosystem only. | |
| 45 | - | ||
| 46 | - | ## Competitor Deep Dives | |
| 47 | - | ||
| 48 | - | ### 1. Feedly | |
| 49 | - | ||
| 50 | - | Cloud-first RSS reader with AI-powered features (Leo AI). Dominant in the market. Free tier limited to 100 sources and 3 feeds. Pro ($8/mo) adds search, notes, integrations. Pro+ ($18/mo) adds AI. | |
| 51 | - | ||
| 52 | - | **What BB lacks:** AI-powered topic tracking, team collaboration, third-party integrations (Slack, Zapier, IFTTT), mobile apps, OPML board view. | |
| 53 | - | ||
| 54 | - | ### 2. NetNewsWire | |
| 55 | - | ||
| 56 | - | Open-source, macOS/iOS native RSS reader. Fast, clean, no monetization. Syncs via iCloud or third-party services (Feedbin, Feedly, etc.). The closest competitor in philosophy (free, local-first, no tracking). | |
| 57 | - | ||
| 58 | - | **What BB lacks:** iCloud sync, iOS app. **What NNW lacks:** non-RSS sources, plugin system, Windows/Linux, E2E encrypted sync, reader view. | |
| 59 | - | ||
| 60 | - | ### 3. Inoreader | |
| 61 | - | ||
| 62 | - | Web-based RSS reader with powerful automation rules. Free tier has 150 sources. Pro adds rules, active search, article translation. Closest to BB in filtering power (saved searches, rules). | |
| 63 | - | ||
| 64 | - | **What BB lacks:** automation rules, email newsletters as feeds, mobile apps, social media monitoring. | |
| 65 | - | ||
| 66 | - | ### 4. Miniflux | |
| 67 | - | ||
| 68 | - | Minimalist, self-hosted RSS reader (Go). $15/yr hosted or free self-host. Fast, no bloat, REST API. Appeals to the same audience (developers who want simplicity). | |
| 69 | - | ||
| 70 | - | **What BB lacks:** REST API, self-hosted option, bookmarklet. **What Miniflux lacks:** non-RSS sources, plugins, desktop app, E2E sync, feed health tracking. | |
| 71 | - | ||
| 72 | - | ### 5. FreshRSS | |
| 73 | - | ||
| 74 | - | Self-hosted PHP RSS aggregator with extensions. Mature, well-maintained, large community. Extension system exists but is PHP-based and server-side. | |
| 75 | - | ||
| 76 | - | **What BB lacks:** self-hosted option, large extension ecosystem, multi-user support. | |
| 77 | - | ||
| 78 | - | ## Common Missing Features | |
| 79 | - | ||
| 80 | - | ### Worth Adding | |
| 81 | - | - **Mobile companion** -- reading on the go is a core RSS use case | |
| 82 | - | - **OPML import/export** -- standard for RSS reader migration | |
| 83 | - | - **Keyboard shortcuts documentation** -- power users expect discoverable shortcuts | |
| 84 | - | ||
| 85 | - | ### Consider | |
| 86 | - | - **Podcast support** -- audio feeds with inline playback | |
| 87 | - | - **Newsletter-to-feed** -- email newsletters as feed sources (Kill the Newsletter style) | |
| 88 | - | - **REST API** -- local API for scripting and automation | |
| 89 | - | ||
| 90 | - | ### Skip | |
| 91 | - | - **AI summarization** -- contradicts the "read the source" philosophy | |
| 92 | - | - **Team/collaboration features** -- BB is a personal tool | |
| 93 | - | - **Social features** -- no sharing, commenting, or recommendation algorithms | |
| 94 | - | ||
| 95 | - | ## What We Offer That Competitors Don't | |
| 96 | - | ||
| 97 | - | - **Rhai plugin system** -- write a plugin for any data source in ~50-100 lines. No other desktop reader is extensible this way. | |
| 98 | - | - **11 built-in source types** -- RSS, HN, arXiv, GitHub Trending, Dev.to, Lobsters, XKCD, NASA APOD, earthquakes, NWS alerts, web reader. No competitor covers this breadth out of the box. | |
| 99 | - | - **Feed health tracking** -- visual per-source status with circuit breaker (auto-disable after 10 consecutive failures, auto-recovery on success). | |
| 100 | - | - **URL tracker stripping** -- automatic removal of utm_*, fbclid, gclid, and other tracking parameters. | |
| 101 | - | - **E2E encrypted cross-device sync** -- SyncKit with ChaCha20-Poly1305. Server never sees plaintext feed data. | |
| 102 | - | - **Cross-platform desktop native** -- macOS, Windows, Linux via Tauri 2. NetNewsWire is Mac-only; Reeder is Apple-only; everything else is web. | |
| 103 | - | ||
| 104 | - | ## Target Users | |
| 105 | - | ||
| 106 | - | - Developers who follow multiple sources (HN, arXiv, RSS, GitHub) and want them unified | |
| 107 | - | - Privacy-conscious users who want local-first with optional E2E sync | |
| 108 | - | - Power users who want to script custom feed sources | |
| 109 | - | - Anyone frustrated with Feedly's paywall or Google Reader's demise |
| @@ -1,63 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast — Build & Deploy | |
| 2 | - | ||
| 3 | - | See `_meta/docs/deploy.md` for shared infrastructure (machines, git remotes, collection layout, shared deps). | |
| 4 | - | ||
| 5 | - | ## Artifacts | |
| 6 | - | ||
| 7 | - | | Platform | Artifact | Machine | | |
| 8 | - | |----------|----------|---------| | |
| 9 | - | | macOS aarch64 | .dmg | local | | |
| 10 | - | | Linux aarch64 | AppImage, .deb, .rpm | astra | | |
| 11 | - | | Linux x86_64 | AppImage, .deb, .rpm | pop-os | | |
| 12 | - | | Windows x86_64 | .msi, NSIS .exe | windows-x86 | | |
| 13 | - | ||
| 14 | - | ## Build Commands | |
| 15 | - | ||
| 16 | - | ```bash | |
| 17 | - | # Signing env (see _meta/docs/deploy.md § Tauri Updater Signing for setup). | |
| 18 | - | ||
| 19 | - | # macOS (local): | |
| 20 | - | cd ~/Code/Apps/balanced_breakfast && \ | |
| 21 | - | . ~/.tauri/passwords.env && \ | |
| 22 | - | TAURI_SIGNING_PRIVATE_KEY=$HOME/.tauri/balanced-breakfast.key \ | |
| 23 | - | TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$BB_TAURI_PASSWORD \ | |
| 24 | - | cargo tauri build | |
| 25 | - | cp target/release/bundle/dmg/*.dmg ~/Dist/balanced_breakfast/macos/ | |
| 26 | - | ||
| 27 | - | # Linux aarch64 (astra): | |
| 28 | - | ssh astra 'source ~/.cargo/env && cd ~/Code/Apps/balanced_breakfast && git pull && . ~/.tauri/passwords.env && TAURI_SIGNING_PRIVATE_KEY=$HOME/.tauri/balanced-breakfast.key TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$BB_TAURI_PASSWORD cargo tauri build' | |
| 29 | - | scp astra:~/Code/Apps/balanced_breakfast/target/release/bundle/appimage/*.AppImage ~/Dist/balanced_breakfast/linux-aarch64/ | |
| 30 | - | scp astra:~/Code/Apps/balanced_breakfast/target/release/bundle/deb/*.deb ~/Dist/balanced_breakfast/linux-aarch64/ | |
| 31 | - | ||
| 32 | - | # Linux x86_64 (pop-os): | |
| 33 | - | ssh pop-os 'source ~/.cargo/env && cd ~/Code/Apps/balanced_breakfast && git pull && . ~/.tauri/passwords.env && TAURI_SIGNING_PRIVATE_KEY=$HOME/.tauri/balanced-breakfast.key TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$BB_TAURI_PASSWORD cargo tauri build' | |
| 34 | - | scp pop-os:~/Code/Apps/balanced_breakfast/target/release/bundle/appimage/*.AppImage ~/Dist/balanced_breakfast/linux-x86_64/ | |
| 35 | - | scp pop-os:~/Code/Apps/balanced_breakfast/target/release/bundle/deb/*.deb ~/Dist/balanced_breakfast/linux-x86_64/ | |
| 36 | - | ||
| 37 | - | # Windows (windows-x86): | |
| 38 | - | ssh me@windows-x86 'powershell -Command ". C:\Users\me\.tauri\passwords.ps1; $env:TAURI_SIGNING_PRIVATE_KEY=\"C:\Users\me\.tauri\balanced-breakfast.key\"; $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$env:BB_TAURI_PASSWORD; cd C:\Users\me\Code\Apps\balanced_breakfast; git pull; cargo tauri build"' | |
| 39 | - | scp me@windows-x86:"C:/Users/me/Code/Apps/balanced_breakfast/target/release/bundle/msi/*.msi" ~/Dist/balanced_breakfast/windows/ | |
| 40 | - | scp me@windows-x86:"C:/Users/me/Code/Apps/balanced_breakfast/target/release/bundle/nsis/*-setup.exe" ~/Dist/balanced_breakfast/windows/ | |
| 41 | - | ``` | |
| 42 | - | ||
| 43 | - | ## Project-Specific Notes | |
| 44 | - | ||
| 45 | - | - No `beforeBuildCommand` — no CSS build step. | |
| 46 | - | - Bundles Rhai plugins (`plugins/*.rhai`) and themes (`MNW/shared/themes/*.toml`) as resources. | |
| 47 | - | - Case collision on Windows: `docs/PLUGIN_AUTHORING.md` vs `docs/plugin_authoring.md` — harmless. | |
| 48 | - | - Version is in `src-tauri/tauri.conf.json`. | |
| 49 | - | ||
| 50 | - | ## Troubleshooting | |
| 51 | - | ||
| 52 | - | ### Version mismatch warning (Cargo) | |
| 53 | - | ||
| 54 | - | If you see Cargo warnings about "conflicting units" or "found two Cargo.toml that resolve to the same path", check that `src-tauri/Cargo.toml` version matches the intended release version. A stale version (e.g., 0.3.0 when it should be 0.3.1) causes this. Update `src-tauri/tauri.conf.json` AND `src-tauri/Cargo.toml` together. | |
| 55 | - | ||
| 56 | - | ### sccache crash on Windows (ico crate) | |
| 57 | - | ||
| 58 | - | BB's Tauri build compiles the `ico` crate for Windows icon embedding. If sccache is set as `RUSTC_WRAPPER`, it crashes on this crate. Fix: | |
| 59 | - | ||
| 60 | - | ```powershell | |
| 61 | - | $env:RUSTC_WRAPPER = '' | |
| 62 | - | cargo tauri build | |
| 63 | - | ``` |
| @@ -1,166 +0,0 @@ | |||
| 1 | - | # Schema — Balanced Breakfast | |
| 2 | - | ||
| 3 | - | SQLite database. Migrations applied via `sqlx::migrate!()`. PRAGMA foreign_keys=ON enforced. | |
| 4 | - | ||
| 5 | - | ## Table Map | |
| 6 | - | ||
| 7 | - | | Domain | Tables | Purpose | | |
| 8 | - | |--------|--------|---------| | |
| 9 | - | | Feeds | 2 | Feed sources and fetched items | | |
| 10 | - | | Organization | 2 | Feed tags, query feeds (saved filters) | | |
| 11 | - | | State | 2 | Plugin state, user config | | |
| 12 | - | | Search | 1 | FTS5 virtual table | | |
| 13 | - | | SyncKit | 2 | Changelog + state | | |
| 14 | - | ||
| 15 | - | --- | |
| 16 | - | ||
| 17 | - | ## Feeds | |
| 18 | - | ||
| 19 | - | ### feeds | |
| 20 | - | Feed sources. Each feed uses a specific busser (plugin) to fetch content. | |
| 21 | - | ||
| 22 | - | | Column | Type | Notes | | |
| 23 | - | |--------|------|-------| | |
| 24 | - | | id | TEXT PK | UUID | | |
| 25 | - | | busser_id | TEXT NOT NULL | Plugin identifier (e.g., 'rss', 'hn', 'reddit') | | |
| 26 | - | | name | TEXT NOT NULL | Display name | | |
| 27 | - | | config | TEXT | JSON — plugin-specific config (URL, auth, params) | | |
| 28 | - | | enabled | INTEGER | Boolean — active/paused | | |
| 29 | - | | last_fetch | TEXT | Last successful fetch timestamp | | |
| 30 | - | | consecutive_failures | INTEGER | Circuit breaker counter | | |
| 31 | - | | last_error | TEXT | Most recent error message | | |
| 32 | - | | last_success_at | TEXT | For staleness detection | | |
| 33 | - | | circuit_broken | INTEGER | Boolean — tripped when failures exceed threshold | | |
| 34 | - | | created_at / updated_at | TEXT | | | |
| 35 | - | ||
| 36 | - | **Indexes:** busser_id, enabled. | |
| 37 | - | ||
| 38 | - | **Circuit breaker:** When `consecutive_failures` exceeds threshold, `circuit_broken` is set. The "Reset & Retry" UI action clears both fields and re-enables fetching. | |
| 39 | - | ||
| 40 | - | ### feed_items | |
| 41 | - | Individual items fetched from feeds. The "bite" fields power the compact list view. | |
| 42 | - | ||
| 43 | - | | Column | Type | Notes | | |
| 44 | - | |--------|------|-------| | |
| 45 | - | | id | TEXT PK | UUID | | |
| 46 | - | | feed_id | TEXT FK → feeds CASCADE | | | |
| 47 | - | | external_id | TEXT UNIQUE | Dedup key from source | | |
| 48 | - | | busser_id | TEXT | Plugin that produced this item | | |
| 49 | - | | bite_author / bite_text / bite_secondary / bite_indicator | TEXT | Compact list view fields | | |
| 50 | - | | title | TEXT | Full title | | |
| 51 | - | | body | TEXT | Full content (HTML or text) | | |
| 52 | - | | url | TEXT | Link to original | | |
| 53 | - | | media | TEXT | JSON — images, videos, enclosures | | |
| 54 | - | | published_at | TEXT | Source publish time | | |
| 55 | - | | fetched_at | TEXT | When BB fetched it | | |
| 56 | - | | source_name | TEXT | Display name of source | | |
| 57 | - | | score | INTEGER | Source-specific ranking (e.g., HN points) | | |
| 58 | - | | tags | TEXT | JSON array of tags | | |
| 59 | - | | is_read | INTEGER | Boolean | | |
| 60 | - | | is_starred | INTEGER | Boolean | | |
| 61 | - | | actions | TEXT | JSON — plugin-defined actions (vote, reply) | | |
| 62 | - | ||
| 63 | - | **Indexes:** feed_id, busser_id, published_at, is_read, is_starred. | |
| 64 | - | ||
| 65 | - | --- | |
| 66 | - | ||
| 67 | - | ## Organization | |
| 68 | - | ||
| 69 | - | ### feed_tags | |
| 70 | - | Tags assigned to feeds (not items). Used for grouping feeds in the sidebar. | |
| 71 | - | ||
| 72 | - | | Column | Type | Notes | | |
| 73 | - | |--------|------|-------| | |
| 74 | - | | feed_id | TEXT FK → feeds CASCADE | | | |
| 75 | - | | tag | TEXT | | | |
| 76 | - | ||
| 77 | - | **PK:** (feed_id, tag). | |
| 78 | - | **Index:** idx_feed_tags_tag. | |
| 79 | - | ||
| 80 | - | ### query_feeds | |
| 81 | - | Saved filter configurations that act as virtual feeds. Rules stored as JSON. | |
| 82 | - | ||
| 83 | - | | Column | Type | Notes | | |
| 84 | - | |--------|------|-------| | |
| 85 | - | | id | TEXT PK | UUID | | |
| 86 | - | | name | TEXT NOT NULL | Display name | | |
| 87 | - | | rules | TEXT NOT NULL | JSON — filter rules (source, tags, date range, read status, etc.) | | |
| 88 | - | ||
| 89 | - | --- | |
| 90 | - | ||
| 91 | - | ## State | |
| 92 | - | ||
| 93 | - | ### busser_state | |
| 94 | - | Plugin-scoped key-value storage. Plugins can persist arbitrary state (pagination cursors, auth tokens, etc.) sandboxed by busser_id. | |
| 95 | - | ||
| 96 | - | | Column | Type | Notes | | |
| 97 | - | |--------|------|-------| | |
| 98 | - | | id | TEXT PK | | | |
| 99 | - | | busser_id | TEXT NOT NULL | | | |
| 100 | - | | key | TEXT NOT NULL | UNIQUE(busser_id, key) | | |
| 101 | - | | value | TEXT | | | |
| 102 | - | ||
| 103 | - | ### user_config | |
| 104 | - | App-wide key-value configuration (theme, welcome flag, fetch intervals). | |
| 105 | - | ||
| 106 | - | | Column | Type | Notes | | |
| 107 | - | |--------|------|-------| | |
| 108 | - | | key | TEXT PK | | | |
| 109 | - | | value | TEXT | | | |
| 110 | - | ||
| 111 | - | --- | |
| 112 | - | ||
| 113 | - | ## Full-Text Search | |
| 114 | - | ||
| 115 | - | ### feed_items_fts | |
| 116 | - | FTS5 virtual table using external content mode (content synced from feed_items via triggers). | |
| 117 | - | ||
| 118 | - | | Indexed Column | Source | | |
| 119 | - | |---------------|--------| | |
| 120 | - | | title | feed_items.title | | |
| 121 | - | | body | feed_items.body | | |
| 122 | - | | bite_text | feed_items.bite_text | | |
| 123 | - | ||
| 124 | - | **Triggers:** INSERT/UPDATE/DELETE on feed_items automatically update the FTS index. | |
| 125 | - | ||
| 126 | - | --- | |
| 127 | - | ||
| 128 | - | ## SyncKit Infrastructure | |
| 129 | - | ||
| 130 | - | ### sync_state | |
| 131 | - | Key-value store for sync configuration. Same schema as GO/AF: | |
| 132 | - | - `device_id` — unique device identifier | |
| 133 | - | - `pull_cursor` — last-pulled sequence number | |
| 134 | - | - `auto_sync_enabled` / `sync_interval_minutes` | |
| 135 | - | - `applying_remote` — suppresses changelog triggers during pull | |
| 136 | - | - `last_sync_at` / `initial_snapshot_done` | |
| 137 | - | ||
| 138 | - | ### sync_changelog | |
| 139 | - | Local change log. Synced tables have triggers that write here when `applying_remote` != '1'. | |
| 140 | - | ||
| 141 | - | | Column | Type | Notes | | |
| 142 | - | |--------|------|-------| | |
| 143 | - | | id | INTEGER PK | | | |
| 144 | - | | table_name | TEXT | | | |
| 145 | - | | op | TEXT | 'INSERT', 'UPDATE', 'DELETE' | | |
| 146 | - | | row_id | TEXT | | | |
| 147 | - | | timestamp | TEXT | | | |
| 148 | - | | data | TEXT | Full row as JSON | | |
| 149 | - | | pushed | INTEGER | 0 = unpushed | | |
| 150 | - | ||
| 151 | - | **Index:** idx_sync_changelog_pushed. | |
| 152 | - | ||
| 153 | - | **Synced tables:** feeds, feed_items, feed_tags, query_feeds, busser_state, user_config. | |
| 154 | - | ||
| 155 | - | --- | |
| 156 | - | ||
| 157 | - | ## Cascade Rules | |
| 158 | - | ||
| 159 | - | - **CASCADE:** feed_id on feed_items, feed_id on feed_tags | |
| 160 | - | - No SET NULL or RESTRICT relationships — the schema is flat (feeds own items and tags) | |
| 161 | - | ||
| 162 | - | ## Key Paths | |
| 163 | - | ||
| 164 | - | - `migrations/` — SQL migration files | |
| 165 | - | - `src-tauri/src/db/` — query functions | |
| 166 | - | - `src-tauri/src/orchestrator/` — feed fetch orchestration |
| @@ -1,80 +0,0 @@ | |||
| 1 | - | # Smoke Test Checklist — Balanced Breakfast | |
| 2 | - | ||
| 3 | - | Pre-release manual verification. Run after building a new version. | |
| 4 | - | ||
| 5 | - | ## Launch & Basics | |
| 6 | - | ||
| 7 | - | - [ ] App launches without error | |
| 8 | - | - [ ] Window appears at expected size | |
| 9 | - | - [ ] No console errors on startup | |
| 10 | - | ||
| 11 | - | ## Theme System | |
| 12 | - | ||
| 13 | - | - [ ] Open theme picker | |
| 14 | - | - [ ] Switch theme — colors update immediately | |
| 15 | - | - [ ] Switch back to default | |
| 16 | - | ||
| 17 | - | ## Feed Management | |
| 18 | - | ||
| 19 | - | - [ ] Add an RSS feed by URL | |
| 20 | - | - [ ] Feed fetches and items appear | |
| 21 | - | - [ ] Add a non-RSS feed (e.g., HN plugin if available) | |
| 22 | - | - [ ] Disable a feed — it stops fetching | |
| 23 | - | - [ ] Re-enable — fetch resumes | |
| 24 | - | - [ ] Delete a feed — items removed | |
| 25 | - | ||
| 26 | - | ## Item Interaction | |
| 27 | - | ||
| 28 | - | - [ ] Scroll through feed items | |
| 29 | - | - [ ] Open an item detail view | |
| 30 | - | - [ ] Mark item as read — disappears from unread filter | |
| 31 | - | - [ ] Star an item — appears in starred filter | |
| 32 | - | - [ ] Unstar — removed from starred filter | |
| 33 | - | ||
| 34 | - | ## Filtering & Search | |
| 35 | - | ||
| 36 | - | - [ ] Filter by source (single feed) | |
| 37 | - | - [ ] Filter unread only | |
| 38 | - | - [ ] Filter starred only | |
| 39 | - | - [ ] Search by title text | |
| 40 | - | - [ ] Combined filter: search + source + unread | |
| 41 | - | - [ ] Pagination: scroll past first page of results | |
| 42 | - | ||
| 43 | - | ## Tags | |
| 44 | - | ||
| 45 | - | - [ ] Add tags to a feed | |
| 46 | - | - [ ] Filter items by tag | |
| 47 | - | - [ ] Remove tags | |
| 48 | - | ||
| 49 | - | ## Query Feeds | |
| 50 | - | ||
| 51 | - | - [ ] Create a query feed (saved filter) | |
| 52 | - | - [ ] Query feed shows matching items | |
| 53 | - | - [ ] Edit query conditions | |
| 54 | - | - [ ] Delete query feed | |
| 55 | - | ||
| 56 | - | ## OPML | |
| 57 | - | ||
| 58 | - | - [ ] Export OPML — file downloads | |
| 59 | - | - [ ] Import OPML — feeds appear (duplicates skipped) | |
| 60 | - | ||
| 61 | - | ## Plugins | |
| 62 | - | ||
| 63 | - | - [ ] Verify bundled plugins load (check plugin list) | |
| 64 | - | - [ ] If adding a custom `.rhai` plugin: drop file into plugins dir, restart, verify it appears | |
| 65 | - | ||
| 66 | - | ## Circuit Breaker | |
| 67 | - | ||
| 68 | - | - [ ] If a feed is broken (red status): "Reset & Retry" clears the circuit breaker | |
| 69 | - | - [ ] Feed retries and either recovers or re-trips | |
| 70 | - | ||
| 71 | - | ## Sync (if configured) | |
| 72 | - | ||
| 73 | - | - [ ] Log in to sync | |
| 74 | - | - [ ] Verify feed subscriptions sync to another device | |
| 75 | - | - [ ] Read/star state syncs | |
| 76 | - | ||
| 77 | - | ## Config Persistence | |
| 78 | - | ||
| 79 | - | - [ ] Change a setting (fetch interval, etc.) | |
| 80 | - | - [ ] Restart app — setting persists |
| @@ -1,131 +0,0 @@ | |||
| 1 | - | # Test Plan — Balanced Breakfast | |
| 2 | - | ||
| 3 | - | ## Overview | |
| 4 | - | ||
| 5 | - | ~700 tests total: 96 integration tests + ~600 inline unit tests across crates. All use in-memory SQLite. | |
| 6 | - | ||
| 7 | - | ## Test Architecture | |
| 8 | - | ||
| 9 | - | **Integration tests:** `src-tauri/tests/` directory. 10 test files + shared helpers. | |
| 10 | - | ||
| 11 | - | **Unit tests:** Inline `#[cfg(test)]` modules in 28 source files across all crates. | |
| 12 | - | ||
| 13 | - | **DB pattern:** `Database::connect("sqlite::memory:")` creates a fresh in-memory SQLite database. Migrations run via `db.migrate()`. Full isolation per test. | |
| 14 | - | ||
| 15 | - | **No external dependencies:** No network, no real plugins, no environment variables needed. | |
| 16 | - | ||
| 17 | - | ## Running Tests | |
| 18 | - | ||
| 19 | - | ```bash | |
| 20 | - | # All tests | |
| 21 | - | cargo test --workspace | |
| 22 | - | ||
| 23 | - | # Integration tests only | |
| 24 | - | cargo test --test '*' | |
| 25 | - | ||
| 26 | - | # Specific test file | |
| 27 | - | cargo test --test feeds | |
| 28 | - | cargo test --test items | |
| 29 | - | cargo test --test orchestrator | |
| 30 | - | ||
| 31 | - | # Specific test function | |
| 32 | - | cargo test --test items item_mark_read_unread_roundtrip | |
| 33 | - | ||
| 34 | - | # Single-threaded (debugging) | |
| 35 | - | cargo test -- --test-threads=1 | |
| 36 | - | ``` | |
| 37 | - | ||
| 38 | - | ## What's Covered | |
| 39 | - | ||
| 40 | - | ### Integration Tests (`src-tauri/tests/`) | |
| 41 | - | ||
| 42 | - | | File | Tests | What's Tested | | |
| 43 | - | |------|-------|---------------| | |
| 44 | - | | `feeds.rs` | 19 | Feed CRUD, enabled/disabled, transaction deletion, circuit breaker, source listing with tags | | |
| 45 | - | | `items.rs` | 20 | Item CRUD, upsert deduplication, pagination, source/unread/starred filters, state preservation across upserts | | |
| 46 | - | | `search.rs` | 12 | Title/body/bite text search, combined filters (source + unread + starred), pagination with filters | | |
| 47 | - | | `tags.rs` | 12 | Tag set/replace/clear, list operations, feed-based filtering | | |
| 48 | - | | `opml.rs` | 11 | OPML export/import, XML escaping, duplicate handling, nested outlines, roundtrip | | |
| 49 | - | | `orchestrator.rs` | 10 | Source listing, health status, circuit breaker reset, fetch orchestration | | |
| 50 | - | | `feed_generator.rs` | 9 | Mark read/starred, unread counting, ordering (score, starred-first, unread-first) | | |
| 51 | - | | `query_feeds.rs` | 7 | Query feed CRUD, condition storage, filter integration | | |
| 52 | - | | `config.rs` | 6 | Config CRUD, empty values, overwrites, deletion | | |
| 53 | - | | `themes.rs` | 4 | TOML parsing, minimal/full/empty themes, unknown section handling | | |
| 54 | - | ||
| 55 | - | **Common test helper** (`src-tauri/tests/common/mod.rs`): | |
| 56 | - | ```rust | |
| 57 | - | pub async fn test_db() -> Database // In-memory SQLite with migrations | |
| 58 | - | pub async fn setup(suffix: &str) -> Orchestrator // Full orchestrator with in-memory DB | |
| 59 | - | pub async fn create_rss_feed(db, name, url) -> DbFeed | |
| 60 | - | pub async fn create_other_feed(db, busser_id, name) -> DbFeed | |
| 61 | - | pub async fn insert_item(db, feed, external_id, title, hours_ago) -> DbFeedItem | |
| 62 | - | ``` | |
| 63 | - | ||
| 64 | - | ### Unit Tests (inline across crates) | |
| 65 | - | ||
| 66 | - | | Module | What's Tested | | |
| 67 | - | |--------|---------------| | |
| 68 | - | | `bb-core::crypto` | AES-256-GCM encryption/decryption, key generation, field-level encryption | | |
| 69 | - | | `bb-core::rhai_plugin` | Sandbox creation, operation limits, type conversions | | |
| 70 | - | | `bb-core::orchestrator` | Fetch scheduling, secret encryption | | |
| 71 | - | | `bb-core::plugin_manager` | Plugin lifecycle | | |
| 72 | - | | `bb-core::url_cleaner` | Tracker parameter stripping | | |
| 73 | - | | `bb-feed::ordering` | Score ordering, starred/unread prioritization | | |
| 74 | - | | `bb-feed::generator` | Feed generation, query filtering | | |
| 75 | - | | `bb-db::repository` | Low-level DB operations | | |
| 76 | - | | `bb-db::models` | JSON serialization roundtrips, conditional rules | | |
| 77 | - | | `bb-db::id_types` | UUID generation, string conversion | | |
| 78 | - | | `bb-interface::feed_item` | Feed item construction | | |
| 79 | - | | `bb-interface::error` | Error codes, serialization | | |
| 80 | - | | `bb-interface::busser` | BusserId validation | | |
| 81 | - | | `commands/error.rs` | Error code mapping, SQLx conversion | | |
| 82 | - | | `commands/feeds.rs` | Feed command logic | | |
| 83 | - | | `commands/items.rs` | Item detail conversion | | |
| 84 | - | | `commands/opml.rs` | OPML parsing edge cases | | |
| 85 | - | | `commands/query_feeds.rs` | Query feed command logic | | |
| 86 | - | | `commands/themes.rs` | Theme file parsing | | |
| 87 | - | | `state.rs` | Fetch scheduling (is_single_feed_due, any_feed_due) | | |
| 88 | - | | `sync_service/*` | Download, snapshot, sync orchestration | | |
| 89 | - | ||
| 90 | - | ## What's Not Tested | |
| 91 | - | ||
| 92 | - | | Area | Reason | | |
| 93 | - | |------|--------| | |
| 94 | - | | Plugin runtime (real Rhai execution) | Sandbox limits are unit-tested; real plugin execution against live feeds requires network | | |
| 95 | - | | SyncKit upload/download | Requires MNW server; sync state machine is unit-tested | | |
| 96 | - | | Sync scheduler | Background job scheduling | | |
| 97 | - | | Plugin hot-reload | Edge cases around file watching | | |
| 98 | - | | Theme CSS injection | Rendering concern, verified manually | | |
| 99 | - | | Tauri commands (config, plugins, sources, sync) | Thin wrappers around tested core logic | | |
| 100 | - | | JavaScript frontend | No JS test runner (frontend is minimal) | | |
| 101 | - | ||
| 102 | - | ## Adding New Tests | |
| 103 | - | ||
| 104 | - | ### Integration test | |
| 105 | - | ```rust | |
| 106 | - | #[tokio::test] | |
| 107 | - | async fn my_new_test() { | |
| 108 | - | let db = common::test_db().await; | |
| 109 | - | let feed = common::create_rss_feed(&db, "Test Feed", "https://example.com/rss").await; | |
| 110 | - | let item = common::insert_item(&db, &feed, "rss:1", "Article", 1).await; | |
| 111 | - | // Assertions | |
| 112 | - | } | |
| 113 | - | ``` | |
| 114 | - | ||
| 115 | - | ### Orchestrator test | |
| 116 | - | ```rust | |
| 117 | - | #[tokio::test] | |
| 118 | - | async fn my_orchestrator_test() { | |
| 119 | - | let orch = common::setup("my_test").await; | |
| 120 | - | let db = orch.database(); | |
| 121 | - | // Test through orchestrator | |
| 122 | - | } | |
| 123 | - | ``` | |
| 124 | - | ||
| 125 | - | ## Key Paths | |
| 126 | - | ||
| 127 | - | - `src-tauri/tests/` — Integration tests | |
| 128 | - | - `src-tauri/tests/common/mod.rs` — Test helpers | |
| 129 | - | - `crates/bb-core/src/` — Core logic with inline tests | |
| 130 | - | - `crates/bb-feed/src/` — Feed generation with inline tests | |
| 131 | - | - `crates/bb-db/src/` — Database layer with inline tests |
| @@ -1,118 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast TODO | |
| 2 | - | ||
| 3 | - | ## Status | |
| 4 | - | Done: All pre-beta phases + iOS cfg-gating + OAuth callback auto-poll + synckit.toml embed. Active: None. Next: Register BB sync app on MNW dashboard, then iOS TestFlight upload. | |
| 5 | - | ||
| 6 | - | v0.3.1. Audit grade A. 601 tests (`--workspace`). Rust 2024 edition (2026-05-06). Renamed `gen` → `fg` in tests (reserved keyword). `cargo check --target aarch64-apple-ios` clean as of 2026-05-13. | |
| 7 | - | ||
| 8 | - | --- | |
| 9 | - | ||
| 10 | - | ## UX Audit Findings (2026-05-05) | |
| 11 | - | ||
| 12 | - | ### Discoverability (remaining) | |
| 13 | - | ||
| 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. | |
| 15 | - | ||
| 16 | - | ### Feature Completeness | |
| 17 | - | ||
| 18 | - | - [ ] Add search highlight — inject `<mark>` tags in detail view when search is active (`detail.js:renderDetail`) | |
| 19 | - | - [ ] Add bookmark search — reading list only supports tag filtering, no text search (`bookmarks.js`) | |
| 20 | - | - [ ] Add bulk bookmark operations — delete, tag, export multiple at once | |
| 21 | - | - [ ] Add export all bookmarks as HTML — currently only exports one at a time | |
| 22 | - | ||
| 23 | - | ### Complexity (minor) | |
| 24 | - | ||
| 25 | - | - [ ] Add "all conditions must match" label more prominently in query feed builder — easy to miss the AND-only logic | |
| 26 | - | - [ ] Show live match count preview in query feed builder before saving | |
| 27 | - | ||
| 28 | - | --- | |
| 29 | - | ||
| 30 | - | ## Sync Monetization (remaining) | |
| 31 | - | ||
| 32 | - | - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency) | |
| 33 | - | - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → sync gate passes) | |
| 34 | - | ||
| 35 | - | ## SyncKit Parity with GoingsOn (2026-05-11) (remaining) | |
| 36 | - | ||
| 37 | - | - [x] **synckit.toml** — `include_str!("../../synckit.toml")` + `parse_synckit_toml_key()` parser in state.rs replaces `option_env!("SYNCKIT_API_KEY")`. File contains placeholder `REPLACE_WITH_BB_SYNC_API_KEY`; parser returns None for placeholder so sync stays cleanly disabled until a real key is provisioned. | |
| 38 | - | - [ ] Register BB as a sync app on the MNW dashboard, then replace the placeholder in `synckit.toml` with the issued client identifier. | |
| 39 | - | ||
| 40 | - | --- | |
| 41 | - | ||
| 42 | - | ## Fuzz Findings (2026-05-02) — remaining | |
| 43 | - | ||
| 44 | - | ### NOTE (remaining) | |
| 45 | - | ||
| 46 | - | - [ ] **Aggregate memory per fetch uncapped** (`host_functions.rs:21,25`). 100 requests × 2MB = 200MB during execution. Acceptable: per-plugin, user-installed, single-threaded. | |
| 47 | - | - [ ] ~~**FTS5 rowid instability after VACUUM**~~ (`migrations/sqlite/005_create_fts.sql`). Won't fix: app never runs VACUUM. Documented for awareness. | |
| 48 | - | ||
| 49 | - | --- | |
| 50 | - | ||
| 51 | - | ## Phase 7: Plugin OAuth (Post-beta) | |
| 52 | - | ||
| 53 | - | ### 7A: OAuth Infrastructure | |
| 54 | - | - [ ] New host functions: `oauth_start(provider, scopes)`, `oauth_token(provider)`, `oauth_refresh(provider)` | |
| 55 | - | - [ ] Tauri deep-link handler for OAuth callback URLs | |
| 56 | - | - [ ] Token store (per-plugin, per-account) with encryption at rest | |
| 57 | - | - [ ] Token refresh logic (automatic before expiry) | |
| 58 | - | ||
| 59 | - | ### 7B: Authenticated Plugins (priority order) | |
| 60 | - | - [ ] Mastodon — home timeline, notifications, bookmarks (standard OAuth2, any instance) | |
| 61 | - | - [ ] Reddit — personalized feed, saved posts, private subreddits | |
| 62 | - | - [ ] GitHub — notifications, private repos, org feeds | |
| 63 | - | - [ ] YouTube — subscriptions feed, watch later | |
| 64 | - | - [ ] Bluesky — home feed, notifications (AT Protocol app passwords) | |
| 65 | - | - [ ] StackExchange — inbox, followed questions | |
| 66 | - | - [ ] Dev.to — reading list, followed tags | |
| 67 | - | ||
| 68 | - | ## Phase 4: Plugin Store (Long-term) | |
| 69 | - | - [ ] 4A: Plugin registry backend (manifest format, index, submission flow, CI validation) | |
| 70 | - | - [ ] 4B: Client-side store UI (browse, search, install, update, uninstall) | |
| 71 | - | - [ ] 4C: Publishing from the app | |
| 72 | - | - [ ] 4D: Trust & safety (review queue, permission declarations, abuse reporting, revocation) | |
| 73 | - | ||
| 74 | - | ## Shared Code Extraction (Cross-Project) | |
| 75 | - | - [ ] Updater UI: extract nearly-identical updater.js from GO/BB into shared module | |
| 76 | - | - [ ] Theme loading: deduplicate TOML theme parser across GO/BB/AF | |
| 77 | - | - [ ] Rhai host functions: deduplicate plugin runtime setup across GO/BB/AF | |
| 78 | - | - [ ] Saved queries: unify GO saved views, BB query feeds, AF smart folders into shared pattern | |
| 79 | - | - [ ] FTS5 query building: extract shared SQLite full-text search utilities | |
| 80 | - | ||
| 81 | - | ## Mobile Port (Tauri 2, pre-beta) | |
| 82 | - | ||
| 83 | - | - [ ] Phase 1: CSS foundation (single-column layout, header hidden, safe areas, bottom sheets) | |
| 84 | - | - [ ] Phase 2: Bottom tab bar (Feed / Saved / Sources / + / More) | |
| 85 | - | - [ ] Phase 3: Detail view navigation (full-screen reader, back button, slide transitions) | |
| 86 | - | - [ ] Phase 4: Touch module (swipe actions, pull-to-refresh, long-press, drag-to-dismiss) | |
| 87 | - | - [ ] Phase 5: View adaptations (items, sources, detail, keyboard gating) | |
| 88 | - | - [ ] Phase 6: Tauri mobile build config (dep gating, capabilities, iOS/Android init) | |
| 89 | - | - [ ] Phase 7: Polish (physical devices, accessibility, performance) | |
| 90 | - | - See [todo_mobile.md](./todo_mobile.md) for full mobile breakdown | |
| 91 | - | ||
| 92 | - | ## Desktop Distribution | |
| 93 | - | ||
| 94 | - | ### Remaining | |
| 95 | - | - [ ] Windows build: `cargo tauri build` on Windows, test `.msi`, code-sign | |
| 96 | - | - [ ] Linux build: AppImage (x86_64 + aarch64), .deb | |
| 97 | - | ||
| 98 | - | ## Deferred | |
| 99 | - | - [ ] Podcast/media enclosure plugin | |
| 100 | - | - [ ] Advanced filtering (date ranges, tags, content type) | |
| 101 | - | - [ ] Read history / analytics | |
| 102 | - | - [ ] Plugin sandboxing / security model | |
| 103 | - | - [ ] Replace native confirm() with custom styled modal for feed deletion | |
| 104 | - | ||
| 105 | - | ## Key Paths | |
| 106 | - | ``` | |
| 107 | - | Cargo.toml Workspace config | |
| 108 | - | src-tauri/src/ Tauri app (main, lib, state, commands/) | |
| 109 | - | src-tauri/frontend/ HTML + JS (BB namespace) + CSS | |
| 110 | - | crates/bb-core/src/orchestrator.rs Main coordinator | |
| 111 | - | crates/bb-core/src/plugin_manager.rs Plugin loading | |
| 112 | - | crates/bb-core/src/rhai_plugin.rs Rhai script execution | |
| 113 | - | crates/bb-interface/src/ Plugin trait & types | |
| 114 | - | crates/bb-db/src/ Database (lib, repository, models) | |
| 115 | - | crates/bb-feed/src/ Aggregation & filtering | |
| 116 | - | plugins/ 10 Rhai plugins | |
| 117 | - | migrations/sqlite/ SQLite migrations | |
| 118 | - | ``` |
| @@ -1,143 +0,0 @@ | |||
| 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. |
| @@ -1,62 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast - Mobile Port | |
| 2 | - | ||
| 3 | - | Done: Phases 1-6 (build config + iOS init + iOS cfg-gating). Active: None. Next: iOS simulator smoke test, Android init, Phase 7 polish. | |
| 4 | - | ||
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | ## Phase 6: Tauri Mobile Build Config | |
| 8 | - | - [x] Platform-conditional sqlx bundled sqlite (via workspace `libsqlite3-sys` bundled feature) | |
| 9 | - | - [x] Split capabilities: `default.json` (cross-platform) + `desktop.json` (shell) | |
| 10 | - | - [x] `cargo tauri ios init` → `gen/apple/` | |
| 11 | - | - [x] iOS cfg-gating: `tauri::menu` (desktop-only), updater, shell, window-state all behind `cfg(not(any(target_os = "ios", target_os = "android")))`. `cargo check --target aarch64-apple-ios` clean. | |
| 12 | - | - [ ] `cargo tauri android init` → generates `gen/android/` | |
| 13 | - | - [ ] iOS simulator smoke test (`cargo tauri ios dev`) | |
| 14 | - | - [ ] Android emulator test | |
| 15 | - | - [ ] All CRUD operations verified on mobile WebView | |
| 16 | - | ||
| 17 | - | --- | |
| 18 | - | ||
| 19 | - | ## Phase 7: Polish | |
| 20 | - | - [ ] Inline sort pills above items | |
| 21 | - | - [ ] Physical device testing (iOS + Android) | |
| 22 | - | - [ ] Safe area insets on various device models | |
| 23 | - | - [ ] VoiceOver / TalkBack accessibility | |
| 24 | - | - [ ] Keyboard doesn't obscure inputs in modals | |
| 25 | - | - [ ] Background/foreground transitions | |
| 26 | - | - [ ] Performance: long item lists, large articles | |
| 27 | - | - [ ] Plugin fetch progress indicator on mobile | |
| 28 | - | ||
| 29 | - | --- | |
| 30 | - | ||
| 31 | - | ## Key Files | |
| 32 | - | ||
| 33 | - | | File | Role | | |
| 34 | - | |------|------| | |
| 35 | - | | `src-tauri/frontend/css/styles.css` | All responsive CSS | | |
| 36 | - | | `src-tauri/frontend/js/touch.js` | Gesture utilities | | |
| 37 | - | | `src-tauri/frontend/js/mobile.js` | Mobile interaction wiring | | |
| 38 | - | | `src-tauri/frontend/js/navigation.js` | Bottom tab bar + view switching | | |
| 39 | - | | `src-tauri/Cargo.toml` | Desktop-only dep gating | | |
| 40 | - | | `src-tauri/src/main.rs` | Desktop-only plugin/service gating | | |
| 41 | - | | `dist/release-ios.sh` | iOS build + TestFlight upload script | | |
| 42 | - | | `docs/privacy-policy.md` | Privacy policy (mirrors GoingsOn structure) | | |
| 43 | - | ||
| 44 | - | --- | |
| 45 | - | ||
| 46 | - | ## iOS / TestFlight Distribution | |
| 47 | - | ||
| 48 | - | Pipeline mirrors GoingsOn. Bundle ID `com.balancedbreakfast.app`, team `93C54W92UP`. | |
| 49 | - | ||
| 50 | - | ```bash | |
| 51 | - | ./dist/release-ios.sh # build + export + upload | |
| 52 | - | ./dist/release-ios.sh --build-only # archive only | |
| 53 | - | ./dist/release-ios.sh --upload-only # export + upload existing archive | |
| 54 | - | ``` | |
| 55 | - | ||
| 56 | - | Pre-flight (browser steps, before first upload): | |
| 57 | - | - [ ] Register App ID `com.balancedbreakfast.app` (Certificates, Identifiers & Profiles) | |
| 58 | - | - [ ] App Store Connect: create app record (name: Balanced Breakfast) | |
| 59 | - | - [ ] Verify `cargo tauri ios build` succeeds locally (smoke test) | |
| 60 | - | - [ ] Publish privacy policy at a stable URL — add as Pages section on the BB MNW project (`https://makenot.work/p/balanced-breakfast#section-privacy-policy`) | |
| 61 | - | ||
| 62 | - | Then `./dist/release-ios.sh` and follow the GoingsOn flow (internal testers + external review). See `Apps/goingson/docs/todo_mobile.md` for the full sequence. |