The Online API now requires the "user" project role (claim urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist. - IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload - ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the refresh-token grant to mint a fresh, role-bearing token - OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401, throw a clear "missing 'user' role" error - OnlineSyncService: surface the 401 at Error level (no longer silent) - UI: ZitadelTokenInspector decodes the access token after login and warns early when the "user" role is absent (fail-open); shown in settings - docs: online-inbox-api-contract reflects role-based access (no allowlist) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.7 KiB
Markdown
160 lines
7.7 KiB
Markdown
# ClaudeDo Online Inbox — API Contract & VPS build prompt
|
|
|
|
Status: handoff doc. The **server side** (API + minimal web client) is built and deployed
|
|
VPS-side by a separate Claude instance. This file is the source of truth for the contract
|
|
both ends implement against. The desktop client in this repo is built to match it.
|
|
|
|
---
|
|
|
|
## 1. Concept
|
|
|
|
ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is
|
|
normally fully local (SQLite). The **Online Inbox** is an optional service that lets the
|
|
single owner view their task lists and add new tasks from a phone/browser. The desktop app
|
|
syncs against it.
|
|
|
|
**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing
|
|
else. A task is present online only while it is `Idle` on the desktop. The moment the user
|
|
queues it locally, the desktop removes it from the online store. Running / WaitingForReview /
|
|
Done / Failed / Cancelled tasks never appear online.
|
|
|
|
Sync directions (each one-way per entity → no conflict resolution needed):
|
|
|
|
- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog).
|
|
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
|
desktop pulls down and then owns.
|
|
|
|
Single user. Both the desktop and the web client authenticate as the **same Zitadel user**.
|
|
|
|
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
|
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
|
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
|
|
env var is gone). The access token carries the role in the claim
|
|
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
|
|
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
|
|
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
|
|
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
|
|
after login/refresh includes the claim automatically — no extra scopes are needed.
|
|
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
|
|
|
|
## 2. Idle backlog definition (desktop side)
|
|
|
|
The desktop mirrors only "real" backlog items, not planning internals:
|
|
|
|
- `Status == Idle`
|
|
- `ParentTaskId == null` (no planning/improvement children)
|
|
- `PlanningPhase == None`
|
|
- `BlockedByTaskId == null`
|
|
|
|
## 3. Data model (Postgres)
|
|
|
|
```
|
|
lists
|
|
id text primary key -- GUID supplied by the desktop; reuse verbatim
|
|
name text not null
|
|
updated_at timestamptz not null default now()
|
|
|
|
tasks
|
|
id text primary key -- GUID; SHARED id space (see below)
|
|
list_id text not null references lists(id) on delete cascade
|
|
title text not null
|
|
description text
|
|
imported boolean not null default false -- false = web-created, awaiting desktop pull
|
|
-- true = desktop-owned (mirrored or handed off)
|
|
created_at timestamptz not null default now()
|
|
updated_at timestamptz not null default now()
|
|
```
|
|
|
|
**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports
|
|
under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID.
|
|
All task writes are idempotent upserts keyed on id.
|
|
|
|
**`imported` flag = ownership.**
|
|
- Web `POST /tasks` inserts `imported=false`.
|
|
- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST
|
|
/tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop
|
|
mirror.
|
|
- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition.
|
|
It never touches `imported=false` rows (those are pending handoff).
|
|
|
|
## 4. Endpoints
|
|
|
|
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
|
|
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
|
|
token without the role → `401`. No anonymous access (imported tasks can trigger code
|
|
execution on the user's machine). The desktop client treats a `401` as: force a
|
|
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
|
|
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
|
|
|
|
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
|
|
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
|
> with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for
|
|
> the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop
|
|
> client acquires tokens via its own OIDC flow.)
|
|
|
|
| Method & path | Caller | Body | Response |
|
|
|---|---|---|---|
|
|
| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` |
|
|
| `GET /lists` | web | — | `200 [{ "id", "name" }]` |
|
|
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
|
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
|
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` |
|
|
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
|
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` |
|
|
|
|
Semantics:
|
|
|
|
- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload
|
|
(cascades its tasks). Idempotent.
|
|
- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id.
|
|
- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task
|
|
in the payload (insert with `imported=true`, or update), and DELETE any `imported=true`
|
|
task whose id is not in the payload. `imported=false` rows are untouched. Idempotent.
|
|
- All task ids are client-trusted within the shared space; the server never rewrites an id.
|
|
|
|
## 5. Reconcile loop (desktop, runs each poll cycle)
|
|
|
|
```
|
|
1. PULL: GET /tasks?imported=false
|
|
for each: if no local task with that id → create local TaskEntity
|
|
{ Id = remote.id, ListId = remote.listId, Title, Description,
|
|
Status = Idle, CreatedBy = "online" }
|
|
(skip + log if remote.listId has no local list)
|
|
then POST /tasks/{id}/imported
|
|
2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}]
|
|
3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2)
|
|
```
|
|
|
|
Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local
|
|
Idle set computed in step 3 and survive the mirror replace.
|
|
|
|
## 6. Minimal web client
|
|
|
|
Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page.
|
|
|
|
- Zitadel login.
|
|
- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`).
|
|
- Add-task form → `POST /tasks`.
|
|
- Mobile-first (main use: jotting ideas from a phone).
|
|
- **Create + read only.** No editing, reordering, status changes, or deletes.
|
|
|
|
## 7. Security
|
|
|
|
- Every route auth-gated (`401` on bad token); only static assets / login are public.
|
|
- Validate `listId` on task creation; parameterized queries only.
|
|
- CORS restricted to the web client origin.
|
|
- Don't log task titles/descriptions at info level (user content).
|
|
|
|
## 8. Deliverables from the VPS build
|
|
|
|
Report back so the desktop can be configured:
|
|
|
|
1. **API base URL.**
|
|
2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes,
|
|
and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how
|
|
refresh tokens are issued.
|
|
3. Any env vars / README.
|
|
|
|
Out of scope server-side: task execution (the desktop runs Claude), any task state other
|
|
than the Idle mirror, multi-user / sharing / notifications.
|