Files
claudedo-online/docs/superpowers/specs/2026-06-10-online-inbox-design.md
2026-06-10 07:44:23 +00:00

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.
  • 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 <access_token>. 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 subALLOWED_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:<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.