# Online Inbox — desktop-side design Date: 2026-06-10 Status: approved, implementing Related: `docs/online-inbox-api-contract.md` (the API both ends share) ## Goal Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls web-created tasks down as local `Idle` tasks. Execution stays 100% local. This spec covers only the **desktop side** (this repo). The API + web client are built VPS-side against the shared contract. ## Non-goals - No remote execution; the Worker still runs everything locally. - No syncing of any task state other than the `Idle` mirror. - No multi-user. Single Zitadel user = the owner. - Web client is create + read only. ## Opt-in & where things live - **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's behaviour. Auth only matters once enabled. - Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The opt-in config and the stored refresh token live in `worker.config.json`-adjacent state. - Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for headless token refresh during polling. ## Config (`WorkerConfig`, new `online_inbox` section) ```jsonc "online_inbox": { "enabled": false, "api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev "poll_interval_seconds": 60, "zitadel": { "authority": "", // issuer URL (from VPS report) "client_id": "", "scopes": "openid offline_access" // offline_access → refresh token } } ``` The refresh token is NOT stored in this file. It lives encrypted via `System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at `~/.todo-app/online-inbox.token` and is read/written only by the Worker. ## Components (Worker, new `Online/` folder) ``` Worker/Online/ OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox) Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract) IOnlineInboxApi.cs — typed client surface (one method per endpoint) OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider Interfaces/IOnlineAuthProvider.cs — Task GetAccessTokenAsync(ct) ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config) OnlineTokenStore.cs — DPAPI-backed refresh-token persistence OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5) OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2) ``` ### `IOnlineInboxApi` ``` Task PutListsAsync(IReadOnlyList lists, ct) Task> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported Task PutMirrorAsync(IReadOnlyList tasks, ct) // PUT /tasks/mirror ``` (The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are web-only.) ### `IOnlineAuthProvider` Single method `Task GetAccessTokenAsync(CancellationToken)` returning a bearer token (refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets us: - ship and test the sync engine now with a fake provider, - wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the Zitadel package reference. `ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an access token via the Zitadel package, caches the access token until near expiry. **Marked with a `// TODO(online-inbox): wire once client config is known.`** ### `OnlineSyncService` (the loop) - Hosted only when `online_inbox.enabled == true` (guarded at registration). - Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository` (same pattern as the External MCP app), run the §5 reconcile loop. - Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in). - All failures are caught per-cycle and logged; never crashes the Worker. Network errors back off to the next interval. - Import safety: a pulled task whose `listId` has no local list is skipped + logged (not imported), and NOT marked imported, so it retries once the list exists. Imported tasks land as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally. ## UI (later increment, after VPS report) - Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in / Sign out** (Zitadel browser flow via the package), connection status. - Login produces a refresh token; UI sends it to the Worker via a new hub method `SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`. - Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig` (mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern). - Visual verification is a manual step (flagged — never claimed working without a run). ## Security - Disabled → no network, no token read. - Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs. - Refresh token encrypted at rest (DPAPI CurrentUser). Never logged. - Imported tasks are `Idle` only — no auto-execution path from the web. ## Testing - `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push, unknown-list skip, disabled = no calls, not-logged-in = skipped cycle. - `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle). - **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent with the no-real-Claude-in-tests rule.) - DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper. ## Open items (need the VPS report) - Exact Zitadel authority / client id / scopes / OAuth flow (device-code vs auth-code+PKCE). - Final API base URL. - Whether the Zitadel package is nuget (desktop) — confirm package id + API shape.