From 8cbe1adb324f13b0a242e9edf00dfbf4b676226c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 10 Jun 2026 09:35:20 +0200 Subject: [PATCH] docs(online-inbox): API contract, desktop design spec, and implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/online-inbox-api-contract.md | 139 ++++++++++++++++++ .../plans/2026-06-10-online-inbox.md | 72 +++++++++ .../specs/2026-06-10-online-inbox-design.md | 131 +++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 docs/online-inbox-api-contract.md create mode 100644 docs/superpowers/plans/2026-06-10-online-inbox.md create mode 100644 docs/superpowers/specs/2026-06-10-online-inbox-design.md diff --git a/docs/online-inbox-api-contract.md b/docs/online-inbox-api-contract.md new file mode 100644 index 0000000..240ae00 --- /dev/null +++ b/docs/online-inbox-api-contract.md @@ -0,0 +1,139 @@ +# ClaudeDo Online Inbox — API Contract & VPS build prompt + +Status: handoff doc. The **server side** (API + minimal web client) is built and deployed +VPS-side by a separate Claude instance. This file is the source of truth for the contract +both ends implement against. The desktop client in this repo is built to match it. + +--- + +## 1. Concept + +ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is +normally fully local (SQLite). The **Online Inbox** is an optional service that lets the +single owner view their task lists and add new tasks from a phone/browser. The desktop app +syncs against it. + +**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing +else. A task is present online only while it is `Idle` on the desktop. The moment the user +queues it locally, the desktop removes it from the online store. Running / WaitingForReview / +Done / Failed / Cancelled tasks never appear online. + +Sync directions (each one-way per entity → no conflict resolution needed): + +- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog). +- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the + desktop pulls down and then owns. + +Single user. Both the desktop and the web client authenticate as the **same Zitadel user**. + +## 2. Idle backlog definition (desktop side) + +The desktop mirrors only "real" backlog items, not planning internals: + +- `Status == Idle` +- `ParentTaskId == null` (no planning/improvement children) +- `PlanningPhase == None` +- `BlockedByTaskId == null` + +## 3. Data model (Postgres) + +``` +lists + id text primary key -- GUID supplied by the desktop; reuse verbatim + name text not null + updated_at timestamptz not null default now() + +tasks + id text primary key -- GUID; SHARED id space (see below) + list_id text not null references lists(id) on delete cascade + title text not null + description text + imported boolean not null default false -- false = web-created, awaiting desktop pull + -- true = desktop-owned (mirrored or handed off) + created_at timestamptz not null default now() + updated_at timestamptz not null default now() +``` + +**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports +under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID. +All task writes are idempotent upserts keyed on id. + +**`imported` flag = ownership.** +- Web `POST /tasks` inserts `imported=false`. +- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST + /tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop + mirror. +- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition. + It never touches `imported=false` rows (those are pending handoff). + +## 4. Endpoints + +All endpoints require a valid Zitadel access token (`Authorization: Bearer `). +Missing/invalid/expired → `401`. No anonymous access (imported tasks can trigger code +execution on the user's machine). + +| Method & path | Caller | Body | Response | +|---|---|---|---| +| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` | +| `GET /lists` | web | — | `200 [{ "id", "name" }]` | +| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) | +| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` | +| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` | +| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) | +| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` | + +Semantics: + +- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload + (cascades its tasks). Idempotent. +- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id. +- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task + in the payload (insert with `imported=true`, or update), and DELETE any `imported=true` + task whose id is not in the payload. `imported=false` rows are untouched. Idempotent. +- All task ids are client-trusted within the shared space; the server never rewrites an id. + +## 5. Reconcile loop (desktop, runs each poll cycle) + +``` +1. PULL: GET /tasks?imported=false + for each: if no local task with that id → create local TaskEntity + { Id = remote.id, ListId = remote.listId, Title, Description, + Status = Idle, CreatedBy = "online" } + (skip + log if remote.listId has no local list) + then POST /tasks/{id}/imported +2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}] +3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2) +``` + +Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local +Idle set computed in step 3 and survive the mirror replace. + +## 6. Minimal web client + +Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page. + +- Zitadel login. +- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`). +- Add-task form → `POST /tasks`. +- Mobile-first (main use: jotting ideas from a phone). +- **Create + read only.** No editing, reordering, status changes, or deletes. + +## 7. Security + +- Every route auth-gated (`401` on bad token); only static assets / login are public. +- Validate `listId` on task creation; parameterized queries only. +- CORS restricted to the web client origin. +- Don't log task titles/descriptions at info level (user content). + +## 8. Deliverables from the VPS build + +Report back so the desktop can be configured: + +1. **API base URL.** +2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes, + and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how + refresh tokens are issued. +3. Any env vars / README. + +Out of scope server-side: task execution (the desktop runs Claude), any task state other +than the Idle mirror, multi-user / sharing / notifications. diff --git a/docs/superpowers/plans/2026-06-10-online-inbox.md b/docs/superpowers/plans/2026-06-10-online-inbox.md new file mode 100644 index 0000000..5eeb858 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-online-inbox.md @@ -0,0 +1,72 @@ +# Online Inbox — implementation plan + +Date: 2026-06-10 +Spec: `docs/superpowers/specs/2026-06-10-online-inbox-design.md` +Contract: `docs/online-inbox-api-contract.md` + +TDD, one commit per task, Conventional Commits. Build with `-c Release` per CLAUDE.md. + +## Phase 1 — Worker sync engine (buildable now, no Zitadel package needed) + +### Task 1 — Config +- Add `OnlineInboxConfig` + nested `ZitadelClientConfig` records. +- Add `online_inbox` (`OnlineInbox`) property to `WorkerConfig`; default `enabled=false`. +- `Load` leaves it untouched when absent (defaults = disabled). +- Test: missing section → disabled defaults; populated section round-trips. + +### Task 2 — DTOs + Idle-backlog helper +- `Online/Dtos.cs`: `RemoteList(Id, Name)`, `RemoteTask(Id, ListId, Title, Description, CreatedAt)`, + `MirrorTask(Id, ListId, Title, Description)`. +- `Online/OnlineBacklog.cs`: `static Task> CurrentAsync(TaskRepository/ctx)` + + the filter predicate (Idle, no parent, PlanningPhase None, BlockedBy null). +- Test the filter against real SQLite seeded with mixed tasks. + +### Task 3 — Auth abstraction + token store +- `Online/Interfaces/IOnlineAuthProvider.cs`. +- `Online/OnlineTokenStore.cs`: DPAPI CurrentUser persistence at `~/.todo-app/online-inbox.token`; + `Save(refreshToken)`, `Read()`, `Clear()`. (Windows-only encryption; thin + guarded.) +- A trivial `StaticTokenAuthProvider` (returns a configured token or null) for tests + as the + temporary default until Zitadel is wired. +- Test: token store round-trip (Windows); static provider returns/omits token. + +### Task 4 — API client +- `Online/IOnlineInboxApi.cs` + `Online/OnlineInboxApiClient.cs` (typed `HttpClient`). +- Attaches `Authorization: Bearer` from `IOnlineAuthProvider`; refuses non-HTTPS non-loopback + base URLs; throws a typed `OnlineInboxException` on non-2xx. +- Test with a stubbed `HttpMessageHandler`: each method hits the right path/verb/body; 401 + surfaces; bearer attached. + +### Task 5 — Sync service +- `Online/OnlineSyncService.cs` (`BackgroundService`) implementing the §5 reconcile loop. +- DI: register only when `enabled`; resolve repos per-cycle via a scope. +- Per-cycle try/catch + structured logging; skip when no token; unknown-list skip. +- Test against a **fake `IOnlineInboxApi`** + real SQLite: pull→import→flag creates local Idle + tasks; mirror payload == Idle backlog; lists pushed; unknown list skipped & not flagged; + disabled/no-token = no api calls. + +### Task 6 — Wire-up + docs +- Register the stack in `Program.cs` behind the enabled flag. +- Update `src/ClaudeDo.Worker/CLAUDE.md` (new `Online/` area) and `src/ClaudeDo.Worker/Config` + notes. Add `online_inbox` to the config section. + +## Phase 2 — UI + real auth (AFTER the VPS reports client config) + +### Task 7 — Hub + config plumbing +- Hub: `GetOnlineInboxConfig` / `SetOnlineInboxConfig` / `SetOnlineInboxAuth(refreshToken)` / + `ClearOnlineInboxAuth`. Update `IWorkerClient` + `WorkerClient` + test fakes (both test + projects — see the IWorkerClient-fakes memory). + +### Task 8 — Settings UI +- "Online Inbox" section in `SettingsModalViewModel`: enable toggle, base URL, Sign in/out, + status. Localized keys in en.json + de.json (parity). +- Visual verification = manual (flag it). + +### Task 9 — ZitadelAuthProvider +- Add the Zitadel package reference; implement `ZitadelAuthProvider` (refresh-token → access + token, cached to expiry) using the reported authority/client-id/flow. +- Swap it in for `StaticTokenAuthProvider` in DI when enabled. +- Manual smoke against the live VPS API (tracked, not an automated test). + +## Notes +- No real network / no real Zitadel / no real Claude in any automated test. +- Stage files by explicit path in subagents; sonnet model; build+test+commit by the orchestrator. diff --git a/docs/superpowers/specs/2026-06-10-online-inbox-design.md b/docs/superpowers/specs/2026-06-10-online-inbox-design.md new file mode 100644 index 0000000..7d1302f --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-online-inbox-design.md @@ -0,0 +1,131 @@ +# 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.