7.1 KiB
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.
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. consumedis 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 <access_token>. Verify viajosecreateRemoteJWKSet(new URL('${ZITADEL_ISSUER}/oauth/v2/keys'))withjwtVerify, 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)
- Login (
@kuns/zitadel-auth/vue, redirect to Zitadel hosted login). - List of lists (
GET /api/lists). - Tap a list → its Idle tasks (
GET /api/lists/:id/tasks). - "Add task" form: title + optional description →
POST /api/tasksto 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:<port>/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.