Skip to main content

max / balanced_breakfast

chore: remove internal docs and duplicate files Move internal docs (audit, deploy, smoke/test plans, todos, competition) to private docs store outside the repo. Remove duplicates: PLUGIN_AUTHORING.md (byte-identical to plugin_authoring.md) and schema.md (superseded by database_schema.md).
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 01:47 UTC
Commit: d77ff1b3a44250bccdedc3e2b9c4e758d8ba269e
Parent: 4f47a3b
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
D docs/todo.md -118
@@ -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.