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

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 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 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.