2026-06-10 07:49:40 +00:00

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

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.

Description
No description provided
Readme 277 KiB
Languages
TypeScript 69.7%
Vue 27.9%
Dockerfile 1.8%
JavaScript 0.6%