146 lines
7.1 KiB
Markdown
146 lines
7.1 KiB
Markdown
# 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.
|
|
|
|
```sql
|
|
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 `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)
|
|
|
|
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.
|