# ClaudeDo Online Inbox Optional online mirror of the [ClaudeDo](https://github.com/) desktop app's **Idle** task backlog. Lets the single owner view their lists and jot new tasks from a phone or browser. The desktop app (local, .NET/SQLite) remains the source of truth; this service only stores/serves data — it never executes tasks. **Live:** https://claudedo.kuns.dev · **API base:** `https://claudedo.kuns.dev/api` ## Governing rule The online store mirrors **exactly** the desktop's Idle tasks. A task exists online only while it is Idle; queuing it on the desktop deletes it here. Running/Done/Failed/review tasks never appear online. - **Lists** — desktop → online only (full-replace catalog). - **Idle tasks** — two-way creation: the desktop pushes its Idle tasks (PUT) and pulls web-created ones (`consumed=false` → import → consume). ## Stack Nuxt 3 (Vue 3, TypeScript, Bun) running as an SPA (`ssr: false`) with Nitro server routes as the API, on shared PostgreSQL, behind Zitadel auth, deployed via Coolify. - Web client login: [`@kuns/zitadel-auth`](../kuns-zitadel) (vendored under `vendor/`). - API token validation: `jose` against Zitadel JWKS. - DB access: `postgres` (postgres.js), parameterized queries only. ## API Every `/api/**` route requires a valid Zitadel **access token** (`Authorization: Bearer …`) carrying the `user` project role. Missing/invalid/expired/role-less → `401`. No anonymous access. **Ownership:** every row carries `owner_id` = the writer's token `sub`. All reads and writes are scoped server-side to the caller (`owner_id = sub OR owner_id IS NULL`); full-replace endpoints only replace the caller's partition. `owner_id IS NULL` marks legacy pre-multi-user rows — visible to all authorized users and adopted by the next write that touches them. DTOs expose this as a nullable `ownerId`; any client-supplied `ownerId` is ignored. | Method & path | Caller | Behaviour | |---|---|---| | `PUT /api/lists` | desktop | Body `[{id,name}]` = full catalog. Upserts all, deletes lists not in payload (cascades tasks). → 200 | | `GET /api/lists` | web | → 200 `[{id,name,ownerId}]` | | `GET /api/lists/{id}/tasks` | web | → 200 tasks for the list; 404 if unknown | | `POST /api/tasks` | web | Body `{title, description?, listId}`. Server-generated GUID, `source=web`, `consumed=false`. 404 if listId unknown. → 201 with the created task | | `PUT /api/tasks/mirror` | desktop | Body `[{id, listId, title, description?}, ...]` = the FULL current Idle backlog (camelCase; `[]` is valid). Full-replace of the desktop-owned partition: upsert each (as `consumed=true`), delete any `consumed=true` task not in the array, leave web-created `consumed=false` tasks untouched. Mirrors `PUT /lists`. → 200 | | `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, ownerId, createdAt}]` web tasks not yet imported (awaiting pull) | | `POST /api/tasks/{id}/consume` | desktop | Sets `consumed=true` (imports a pulled web task into the desktop partition). Idempotent. → 200; 404 if unknown | | `PUT /api/tasks/{id}` · `DELETE /api/tasks/{id}` | desktop | Legacy per-task upsert/delete — **superseded by `PUT /tasks/mirror`**. Kept for compatibility. | The `consumed` flag is the desktop-owned partition marker: `consumed=false` = web-created awaiting pull; `consumed=true` = imported/desktop-owned (managed by the mirror). Task ids are a **shared GUID space**: web-created ids are reused verbatim by the desktop on import; all task writes are idempotent upserts keyed on `id`. ## Zitadel configuration (for the desktop client) Both the desktop and the web client authenticate as the **same Zitadel user** (the owner). Project **ClaudeDo** has two PKCE apps (both issue JWT access tokens): | | Web | Desktop | |---|---|---| | Client id | `376787352019861775` | `376787352137302287` | | App type | User-Agent (SPA) | Native | | Auth method | PKCE (none) | PKCE (none) | | Grants | authorization_code | authorization_code, refresh_token | | Redirect | `https://claudedo.kuns.dev/auth/callback` | `http://localhost:8765/callback`, `http://127.0.0.1:8765/callback` | **Desktop OnlineInbox settings:** - Issuer: `https://auth.kuns.dev` - Client id: `376787352137302287` - Scopes: `openid profile email offline_access urn:zitadel:iam:org:project:id:376787351902355727:aud` - `offline_access` → refresh token for headless re-auth. - the `…:aud` scope puts the project id into the token's `aud` so the API validates it. - Flow: Authorization Code + PKCE on a loopback redirect for the initial interactive login, then refresh-token grant headlessly. Store the refresh token securely. - API base URL: `https://claudedo.kuns.dev/api` Project id: `376787351902355727`. Owner user id (`sub`): `365090688972947729` (`mika@kuns.dev`). ## Environment variables Server-only values are read from `process.env` at runtime (set them in Coolify): | Var | Purpose | |---|---| | `DATABASE_URL` | `postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo` (shared PG, internal host) | | `ZITADEL_ISSUER` | `https://auth.kuns.dev` | | `ZITADEL_AUDIENCE` | accepted audiences (CSV): web id, desktop id, project id | | `REQUIRED_ROLE` | Zitadel project role required for API access (default `user`; grant it to accounts in Zitadel) | | `WEB_ORIGIN` | CORS allowed origin (`https://claudedo.kuns.dev`) | Public web-client config is **baked at build time** (non-secret) via Dockerfile build args (`NUXT_PUBLIC_ZITADEL_ISSUER`, `NUXT_PUBLIC_ZITADEL_CLIENT_ID`, `NUXT_PUBLIC_ZITADEL_PROJECT_ID`); override with `--build-arg` if the ids change. See `.env.example` for local development. ## Development ```bash bun install # Point at a local/tunnelled Postgres and apply the schema: DATABASE_URL=postgres://… bun run migrate DATABASE_URL=postgres://… bun run dev # http://localhost:3000 # Tests (need a Postgres test DB): DATABASE_URL=postgres://…/claudedo_test bun run test ``` Local API smoke without a Zitadel token: set `AUTH_DEV_BYPASS=1` (dev mode only — it is dead-code-eliminated from production builds). ## Database DB `claudedo` on the shared PostgreSQL instance. Schema (`server/utils/schema.ts`) is applied idempotently on server startup by `server/plugins/migrate.ts` (advisory-locked) and via `bun run migrate`. ## Deployment Coolify builds the `Dockerfile` (Bun build → Node runtime) on push to Gitea; Traefik routes `claudedo.kuns.dev` with automatic SSL.