# ClaudeDo Online Inbox — Design **Date:** 2026-06-10 **Status:** Approved **Repo:** `claudedo-online` → deployed at `https://claudedo.kuns.dev` ## Purpose Optional online mirror of the ClaudeDo desktop app's **Idle** task backlog, so the single owner can view lists and jot new tasks from a phone/browser. The desktop (.NET, SQLite, local) is the source of truth; this service stores/serves data only — it never executes tasks. **Governing rule:** the online store mirrors EXACTLY the desktop's Idle tasks. A task is present online only while Idle. Queuing it on the desktop → desktop DELETEs it here. Running/Done/Failed/review tasks never appear online. Two synced entities: - **Lists** — desktop → online only (full-replace catalog; desktop is source of truth). - **Idle tasks** — two-way creation. Desktop pushes its Idle tasks up (PUT); the web creates new ones (POST, `consumed=false`); the desktop pulls unconsumed, imports them, then POSTs consume. ## Stack & Architecture Single Nuxt 3 deployable (TypeScript, Bun), `ssr: false` (SPA mode) so the `@kuns/zitadel-auth/vue` browser-OIDC package works directly. Built with `nuxt build` (node-server output) — **not** `nuxt generate` — so Nitro still serves the API. | Layer | Implementation | |---|---| | Web client | Vue 3 SPA pages, mobile-first; login via `@kuns/zitadel-auth/vue` | | API | Nitro server routes under `/api/**` | | DB access | `postgres` (postgres.js), tagged-template parameterized queries only | | Auth | `jose` JWKS validation of Zitadel access tokens (mirrors preis-tracker) | | Deploy | Coolify → Docker (Bun) → Traefik; domain `claudedo.kuns.dev` | Web + API share one origin, so the web client makes same-origin `/api` calls. The desktop is a non-browser HTTP client (CORS not enforced on it). CORS is still configured to allow only the configured web origin, per spec. **Desktop API base URL:** `https://claudedo.kuns.dev/api` ## Data model (Postgres DB `claudedo`, shared PG16 instance) Migration `server/db/migrations/0001_init.sql`, applied idempotently at container start. ```sql create table if not exists lists ( id text primary key, -- GUID supplied by desktop, reused verbatim name text not null, updated_at timestamptz not null default now() ); create table if not exists tasks ( id text primary key, -- GUID, SHARED id space list_id text not null references lists(id) on delete cascade, title text not null, description text, source text not null, -- 'web' | 'desktop' consumed boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_tasks_list_id on tasks(list_id); create index if not exists idx_tasks_consumed on tasks(consumed) where consumed = false; ``` - **Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports under that SAME id (never duplicates). Desktop tasks arrive with their own GUID via PUT. All task writes are idempotent upserts keyed on `id`. - `consumed` is the web→desktop handoff flag. ## Endpoints (exact contract, namespaced under `/api`) | Method & path | Caller | Nitro file | Behaviour | |---|---|---|---| | `PUT /api/lists` | desktop | `server/api/lists.put.ts` | Body `[{id,name}]` = full catalog. Upsert all; DELETE lists not in payload (cascades tasks). Idempotent. → 200 | | `GET /api/lists` | web | `server/api/lists.get.ts` | → 200 `[{id,name}]` | | `GET /api/lists/:id/tasks` | web | `server/api/lists/[id]/tasks.get.ts` | → 200 tasks for list; 404 if list unknown | | `POST /api/tasks` | web | `server/api/tasks.post.ts` | Body `{title, description?, listId}`. Insert server GUID, `source='web'`, `consumed=false`. listId must exist (404). → 201 with created task incl. id | | `PUT /api/tasks/:id` | desktop | `server/api/tasks/[id].put.ts` | Body `{listId,title,description?}`. Idempotent upsert (`source='desktop'` on insert). → 200 (existing) / 201 (new) | | `DELETE /api/tasks/:id` | desktop | `server/api/tasks/[id].delete.ts` | Idempotent. → 204 (even if absent) | | `GET /api/tasks?consumed=false` | desktop | `server/api/tasks.get.ts` | → 200 `[{id,listId,title,description,createdAt}]` web-created, not yet imported | | `POST /api/tasks/:id/consume` | desktop | `server/api/tasks/[id]/consume.post.ts` | Set `consumed=true`. Idempotent. → 200; 404 if unknown | Validation: `listId` existence checked on POST/PUT task; bad/missing body fields → 400. ## Auth `server/middleware/auth.ts` gates **only** `/api/**` (static SPA shell + JS assets are public; the SPA performs the login redirect client-side). - Require `Authorization: Bearer `. Verify via `jose` `createRemoteJWKSet(new URL('${ZITADEL_ISSUER}/oauth/v2/keys'))` with `jwtVerify`, checking issuer + expiry + `aud` (must include one of the configured audiences: the web client id, desktop client id, or project id). - Enforce single owner: token `sub` ∈ `ALLOWED_USER_IDS`. Otherwise 401/403. - Missing/invalid/expired token → **401**. No anonymous access. - Do not log task titles/descriptions at info level (user content). ## Web UI (mobile-first; create + read ONLY) 1. Login (`@kuns/zitadel-auth/vue`, redirect to Zitadel hosted login). 2. List of lists (`GET /api/lists`). 3. Tap a list → its Idle tasks (`GET /api/lists/:id/tasks`). 4. "Add task" form: title + optional description → `POST /api/tasks` to the selected list. No editing, reordering, status changes, or deletes. ## Zitadel provisioning (via `ZITADEL_SERVICE_TOKEN` Management API) Project **ClaudeDo** with two PKCE apps (both issue JWT access tokens; same owner user): | App | Type | Grant | Redirect | |---|---|---|---| | ClaudeDo Web | User-Agent (SPA) | Auth Code + PKCE | `https://claudedo.kuns.dev/auth/callback`, post-logout `https://claudedo.kuns.dev` | | ClaudeDo Desktop | Native | Auth Code + PKCE + **offline_access** (refresh token, headless) | loopback `http://localhost:/callback` | API validates `aud` against both client IDs + the project ID (project-audience scope `urn:zitadel:iam:org:project:id:{projectId}:aud` requested by clients). ## Environment variables | Var | Used by | Notes | |---|---|---| | `DATABASE_URL` | API | `postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo` | | `ZITADEL_ISSUER` | API + web | `https://auth.kuns.dev` | | `ZITADEL_AUDIENCE` | API | comma list: web id, desktop id, project id | | `ALLOWED_USER_IDS` | API | owner `sub` allowlist | | `WEB_ORIGIN` | API | CORS allowed origin = `https://claudedo.kuns.dev` | | `NUXT_PUBLIC_ZITADEL_CLIENT_ID` | web | ClaudeDo Web client id | | `NUXT_PUBLIC_ZITADEL_ISSUER` | web | `https://auth.kuns.dev` | ## Deliverables API + migration, web client, Coolify deploy, README, and a report covering: (1) API base URL, (2) Zitadel config the desktop must use (issuer, client id, scopes incl. `offline_access`, redirect/refresh setup), (3) env vars. ## Out of scope No task execution (desktop runs Claude). No task states beyond the Idle mirror. No multi-user, sharing, or notifications. Web client is create+read only.