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

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.