132 lines
6.3 KiB
Markdown
132 lines
6.3 KiB
Markdown
# 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<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 `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 <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, 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.
|