max / balanced_breakfast
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` |