6.3 KiB
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
Idlemirror. - 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
BackgroundServices). The opt-in config and the stored refresh token live inworker.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)
"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<string?> 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<RemoteList> lists, ct)
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
Task PutMirrorAsync(IReadOnlyList<MirrorTask> 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<string?> 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
ZitadelAuthProvideronce 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 <zitadel package> 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, resolveTaskRepository+ListRepository(same pattern as the External MCP app), run the §5 reconcile loop. - Skips a cycle (logs at debug) if
GetAccessTokenAsyncreturns 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
listIdhas no local list is skipped + logged (not imported), and NOT marked imported, so it retries once the list exists. Imported tasks land asStatus=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 throughOnlineTokenStore. - Config read/write via hub methods
GetOnlineInboxConfig/SetOnlineInboxConfig(mirrors the existingGetAppSettings/UpdateAppSettingspattern). - 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; refusehttp://non-loopback base URLs. - Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
- Imported tasks are
Idleonly — no auto-execution path from the web.
Testing
OnlineSyncServicereconcile logic tested against a fakeIOnlineInboxApi+ 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.OnlineBacklogfilter 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.