Skip to main content

max / balanced_breakfast

Add CONTRIBUTING.md Extract coding patterns (orchestrator/plugin split, thin commands, BB.* namespace, Rhai sandbox, circuit breaker, secret encryption) from CLAUDE.md into a human-readable contributor guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-15 22:44 UTC
Commit: e4e09e39e18c2bc3f363df7470d4d23a0d4edf5f
Parent: 674dbbd
1 file changed, +258 insertions, -0 deletions
@@ -0,0 +1,258 @@
1 + # Contributing to Balanced Breakfast
2 +
3 + Patterns, conventions, and rules for working on the Balanced Breakfast codebase.
4 +
5 + ## Project Structure
6 +
7 + ```
8 + balanced_breakfast/ (workspace root)
9 + Cargo.toml # Workspace definition
10 + crates/
11 + bb-interface/ # Shared types (leaf crate, no internal deps)
12 + bb-core/ # Orchestrator, Rhai plugin runtime, crypto
13 + bb-feed/ # Feed aggregation, ordering, scoring
14 + bb-db/ # SQLite persistence, repositories
15 + src-tauri/
16 + src/
17 + main.rs # Tauri setup
18 + state.rs # AppState, scheduler, abort handles
19 + commands/ # Tauri commands (thin wrappers)
20 + sync_service.rs # SyncKit change tracking
21 + frontend/
22 + js/ # JavaScript modules (IIFE + BB.* namespace)
23 + css/ # Styles
24 + tests/ # Command integration tests
25 + plugins/ # Bundled Rhai plugins (rss, mastodon, hackernews, etc.)
26 + migrations/sqlite/ # SQLx SQLite migrations
27 + ```
28 +
29 + ### Crate Dependency Flow
30 +
31 + ```
32 + bb-interface (leaf, shared types)
33 +
34 + bb-core (orchestrator, Rhai runtime)
35 + bb-feed (aggregation, ordering)
36 + bb-db (SQLite persistence)
37 +
38 + src-tauri (Tauri shell)
39 + ```
40 +
41 + **Strict rule:** `bb-interface` has no internal dependencies. Library crates never depend on `src-tauri`.
42 +
43 + ## Orchestrator Pattern
44 +
45 + The `Orchestrator` (`bb-core::orchestrator`) is the central coordinator. It owns the `Database` and a `PluginManager` behind `Arc<RwLock<>>`.
46 +
47 + **Responsibilities:**
48 + - Plugin lifecycle (load, init, fetch, shutdown)
49 + - Upserting fetched items (with HTML sanitization and URL tracker stripping)
50 + - Secret encryption for plugin configs
51 + - Circuit breaker enforcement (10 consecutive failures disables auto-fetch)
52 +
53 + **Lock discipline:** Hold the `PluginManager` lock only during synchronous operations. Release before any `.await`:
54 +
55 + ```rust
56 + // GOOD: Release lock before async DB ops
57 + let fetch_result = {
58 + let plugins = self.plugins.read().await;
59 + plugins.fetch(plugin_id, None)
60 + };
61 + // Lock released here, now safe to await
62 + self.db.feeds().record_fetch_success(feed_id).await?;
63 + ```
64 +
65 + ## Plugin System
66 +
67 + ### Contract: 4 Required Functions
68 +
69 + Every `.rhai` plugin must implement:
70 +
71 + | Function | Returns | Purpose |
72 + |----------|---------|---------|
73 + | `fn id()` | `String` | Unique identifier (e.g., `"rss"`) |
74 + | `fn name()` | `String` | Display name (e.g., `"RSS"`) |
75 + | `fn config_schema()` | `Map` | `#{ description, fields: [...] }` |
76 + | `fn fetch(config, cursor)` | `Map` | `#{ items: [...], has_more: bool }` |
77 +
78 + Config field types: `Text`, `TextArea`, `Secret`, `Url`, `Number`, `Toggle`, `Select`.
79 +
80 + ### Sandbox Limits
81 +
82 + Each plugin runs in an isolated Rhai engine with strict limits:
83 +
84 + | Limit | Value | Purpose |
85 + |-------|-------|---------|
86 + | Max operations | 100,000 | Catches infinite loops |
87 + | Max expression depth | 128 | Prevents stack overflow |
88 + | Max recursion | 32 | Limits call depth |
89 + | HTTP timeout | 15s per request | Prevents hanging |
90 + | Response size | 2 MB per response | Prevents memory exhaustion |
91 + | Max requests | 100 per fetch | Catches runaway fetchers |
92 + | Aggregate timeout | 60s per fetch | Hard ceiling on total fetch time |
93 + | URL restrictions | Block localhost, private IPs | Prevents SSRF |
94 +
95 + Each plugin gets its own engine instance with isolated counters (request count via `Arc<AtomicUsize>`, deadline via `Arc<AtomicU64>`).
96 +
97 + ### Host Functions
98 +
99 + Plugins call host-provided functions: `http_get(url)`, `http_get_json(url)`, `parse_feed(xml)`, `parse_html(html)`, `extract_readability(url)`, `truncate(s, n)`, `to_json(val)`, `from_json(s)`, etc. See `plugins/` for bundled examples.
100 +
101 + ## Thin Tauri Commands
102 +
103 + Commands in `src-tauri/src/commands/` are thin wrappers. They extract parameters, call orchestrator or repository methods, and return serialized response types:
104 +
105 + ```rust
106 + #[tauri::command]
107 + #[instrument(skip_all)]
108 + pub async fn list_items(
109 + state: State<'_, Arc<AppState>>,
110 + filter: ItemFilter,
111 + ) -> Result<Vec<ItemSnapshot>, ApiError> {
112 + let db = state.orchestrator.database();
113 + let items = db.items().list(filter).await?;
114 + Ok(items.into_iter().map(ItemSnapshot::from).collect())
115 + }
116 + ```
117 +
118 + **Rules:**
119 + - Every command: `#[instrument(skip_all)]`, returns `Result<T, ApiError>`
120 + - No business logic in commands — that belongs in library crates
121 + - Response types use `#[serde(rename_all = "camelCase")]` for JS consumption
122 +
123 + ## Error Types
124 +
125 + All commands return `Result<T, ApiError>`. The error has typed codes:
126 +
127 + ```rust
128 + pub enum ApiErrorCode {
129 + BadRequest, // User-caused errors
130 + NotFound, // Missing resource
131 + Database, // DB errors
132 + Internal, // Unexpected errors
133 + Plugin, // Rhai plugin errors
134 + }
135 +
136 + pub struct ApiError {
137 + pub code: ApiErrorCode,
138 + pub message: String,
139 + }
140 + ```
141 +
142 + `ApiError` implements `From` for `sqlx::Error`, `FeedError`, and `OrchestratorError`. The frontend receives `{code: "BAD_REQUEST", message: "..."}`.
143 +
144 + ## Circuit Breaker
145 +
146 + Feeds are auto-disabled after 10 consecutive fetch failures:
147 +
148 + - `consecutive_failures` counter increments on each failure, resets to 0 on success.
149 + - When the counter reaches 10, `circuit_broken` is set to true.
150 + - Circuit-broken feeds skip auto-fetch scheduling.
151 + - Users can manually reset via `reset_circuit_breaker` command.
152 +
153 + ## Secret Encryption
154 +
155 + Plugin configs with `Secret`-type fields are encrypted at rest using AES-256-GCM:
156 +
157 + - Format: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>`
158 + - Key stored in OS keychain (via `keyring` crate), with file fallback at `~/.config/balanced-breakfast/encryption.key`
159 + - Backward compatible: unencrypted values are read as plaintext, encrypted on next save
160 + - `encrypt_config_secrets()` / `decrypt_config_secrets()` operate on JSON values in-place, using the config schema to identify Secret fields
161 +
162 + ## JavaScript Architecture
163 +
164 + ### BB.* Namespace
165 +
166 + All JavaScript lives under the `BB` global namespace (defined in `bb.js`, loaded first):
167 +
168 + ```javascript
169 + window.BB = {
170 + api: {}, // Tauri IPC wrappers (thin invoke calls)
171 + state: {}, // Centralized reactive state (Proxy-based)
172 + ui: {}, // Toast, modal, form builder, confirm dialog
173 + utils: {}, // escapeHtml, escapeAttr, sanitizeHtml, debounce
174 + sources: {}, // Source list, tag filters, feed CRUD
175 + items: {}, // Item list, read/star toggle, pagination
176 + detail: {}, // Detail panel, reader view
177 + feeds: {}, // Feed management, OPML import/export
178 + themes: {}, // Theme loading
179 + sync: {}, // Cloud sync settings
180 + app: {}, // Bootstrap, keyboard shortcuts
181 + };
182 + ```
183 +
184 + ### Module Pattern
185 +
186 + Every JS file is a strict-mode IIFE:
187 +
188 + ```javascript
189 + (function() {
190 + 'use strict';
191 + const { escapeHtml, getErrorMessage } = BB.utils;
192 +
193 + async function load() { /* ... */ }
194 + function render(sources) { /* ... */ }
195 +
196 + BB.sources = { load, render };
197 + })();
198 + ```
199 +
200 + ### State Management
201 +
202 + `BB.state` is a Proxy over a data object with pub/sub:
203 +
204 + ```javascript
205 + BB.state.set('sources', data); // Set + notify
206 + BB.state.sources; // Direct read
207 + BB.state.subscribe('items', (newVal, oldVal) => {}); // Subscribe
208 + ```
209 +
210 + ### XSS Prevention
211 +
212 + - `BB.utils.escapeHtml()` for text content
213 + - `BB.utils.escapeAttr()` for HTML attributes
214 + - `BB.utils.sanitizeHtml()` for feed body content (strips script, iframe, on* handlers, javascript: URLs)
215 +
216 + All user-provided content and feed content must be escaped or sanitized before insertion into the DOM.
217 +
218 + ## Testing
219 +
220 + ### Rust Integration Tests
221 +
222 + Integration tests in `src-tauri/tests/` use in-memory SQLite and bypass Tauri `State<>` wrappers:
223 +
224 + ```rust
225 + // Common setup
226 + pub async fn setup(suffix: &str) -> Orchestrator {
227 + let config = OrchestratorConfig {
228 + database_url: "sqlite::memory:".to_string(),
229 + plugins_dir: temp_dir.to_string(),
230 + fetch_interval_secs: 300,
231 + };
232 + let orchestrator = Orchestrator::new(config).await.unwrap();
233 + orchestrator.migrate().await.unwrap();
234 + orchestrator
235 + }
236 + ```
237 +
238 + Tests exercise command-layer code paths without the Tauri runtime. Test helpers in `tests/common/mod.rs` provide setup, fixture creation, and teardown.
239 +
240 + ### JS Tests
241 +
242 + Node.js test runner at `src-tauri/frontend/js/tests/run.js` with browser API mocks. Run with `node src-tauri/frontend/js/tests/run.js`.
243 +
244 + ### Full Pipeline
245 +
246 + Always verify: plugin fetch → DB upsert → command response → JS render. A bug in any layer can appear as a symptom in another.
247 +
248 + ## When Adding a New Source Type
249 +
250 + 1. Create a `.rhai` file in `plugins/` implementing the 4 required functions
251 + 2. Test it manually: add a feed with the new busser, trigger a fetch
252 + 3. The orchestrator auto-discovers plugins on startup — no Rust changes needed
253 +
254 + ## When Adding Features
255 +
256 + 1. **New data type:** Add to `bb-interface` (types), `bb-db` (persistence), then commands
257 + 2. **New command:** Add in `src-tauri/src/commands/`, register in `main.rs`
258 + 3. **New JS module:** Create IIFE file, attach to `BB.moduleName`, add to `index.html`