From 0f0e7ee7d2bdef73b45881a73442d611ab37846c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 07:44:23 +0000 Subject: [PATCH] docs: online inbox design spec --- .gitignore | 10 ++ .../specs/2026-06-10-online-inbox-design.md | 145 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .gitignore create mode 100644 docs/superpowers/specs/2026-06-10-online-inbox-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf0f329 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.nuxt/ +.output/ +.data/ +dist/ +*.log +.env +.env.* +!.env.example +.DS_Store diff --git a/docs/superpowers/specs/2026-06-10-online-inbox-design.md b/docs/superpowers/specs/2026-06-10-online-inbox-design.md new file mode 100644 index 0000000..df0eae6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-online-inbox-design.md @@ -0,0 +1,145 @@ +# 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 `. 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:/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.