Files
ClaudeDo/docs/superpowers/specs/2026-06-10-online-inbox-design.md
2026-06-10 10:02:12 +02:00

7.0 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 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 BackgroundServices). 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)

"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, caches the access token until near expiry. Marked with a // TODO(online-inbox) until the flow is wired.

Auth correction (2026-06-10): the KunsZitadel nuget package is a server-side resource-server helper (AddKunsZitadelJwtBearer token validation). It belongs on the VPS API, NOT the desktop. The desktop must acquire tokens, so ZitadelAuthProvider uses a client OIDC flow — IdentityModel.OidcClient (auth-code + PKCE, loopback redirect) or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the refresh token via OnlineTokenStore.

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/device flow via the OIDC client lib), 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/issuer, client id, scopes, and which grant the Zitadel app is registered for (auth-code+PKCE with which loopback redirect URI, or device-code). This drives the desktop OIDC client implementation.
  • Final API base URL.
  • Desktop client OIDC library decision: IdentityModel.OidcClient (recommended) vs hand-rolled device-code. (KunsZitadel is server-side only — see the auth correction above; it's for the VPS API.)