The desktop pushes its full Idle backlog as a JSON array to /tasks/mirror, not per-task. Previously /tasks/mirror matched tasks/[id].put.ts (id=mirror) and rejected the array with 400. New static route validates per-element, accepts empty arrays, upserts each as consumed=true (desktop-owned), deletes consumed=true rows not in the array, and leaves web-created consumed=false rows untouched. Mirrors PUT /lists.
5.8 KiB
ClaudeDo Online Inbox
Optional online mirror of the ClaudeDo 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(vendored undervendor/). - API token validation:
joseagainst Zitadel JWKS. - DB access:
postgres(postgres.js), parameterized queries only.
API
Every /api/** route requires a valid Zitadel access token (Authorization: Bearer …)
belonging to the owner. Missing/invalid/expired → 401. No anonymous access.
| 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}] |
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, 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:audoffline_access→ refresh token for headless re-auth.- the
…:audscope puts the project id into the token'saudso 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 |
ALLOWED_USER_IDS |
owner sub allowlist (CSV) |
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
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.