Plumb a per-resource owner (Zitadel sub) through the sync contract without enforcing isolation client-side — the server stays the authority. - Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask - JwtClaims: decode the sub claim from the access token (never throws) - OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip pulled tasks owned by a different user (unowned tasks still sync, so single-user behavior is unchanged) - docs: contract documents ownerId + multi-user readiness Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.7 KiB
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 today. Both the desktop and the web client authenticate as the same Zitadel user.
Multi-user readiness (ownerId). Each resource is owned by a Zitadel subject (sub).
RemoteList, RemoteTask, and MirrorTask carry an optional ownerId field. The desktop
stamps its own sub (decoded from the access token) onto everything it pushes, and
defensively ignores any pulled task whose ownerId is set to a different user; an absent
ownerId is treated as unowned/legacy and still syncs. This keeps the contract ready for
multiple users without enforcing isolation client-side — the server remains the
authority that scopes every request by the token's sub. When the server goes multi-user it
should partition all rows by owner and ignore (or validate) the client-supplied ownerId.
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 == IdleParentTaskId == null(no planning/improvement children)PlanningPhase == NoneBlockedByTaskId == 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 /tasksinsertsimported=false. - Desktop pulls
imported=false, creates the task locally (reusing the id), thenPOST /tasks/{id}/importedflips it totrue. From then on the task belongs to the desktop mirror. PUT /tasks/mirroronly ever inserts/updates/deletes within theimported=truepartition. It never touchesimported=falserows (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
KunsZitadelnuget package (feedhttps://git.kuns.dev/api/packages/kuns/nuget/index.json) — callAddKunsZitadel(...)with the Zitadel authority/audience/client id to wireJwtBearervalidation + CORS for the web client origin. (KunsZitadelis 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", "ownerId"? }] — the FULL catalog |
200 |
GET /lists |
web | — | 200 [{ "id", "name", "ownerId"? }] |
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","ownerId"? }] |
POST /tasks/{id}/imported |
desktop | — | 200 (404 if unknown) |
PUT /tasks/mirror |
desktop | [{ "id","listId","title","description","ownerId"? }] — full Idle set |
200 |
ownerId (optional, see §1) is the Zitadel sub of the owner. The desktop sends it on push
and ignores pulled tasks owned by a different user; the server should derive/validate it from
the token rather than trust the client value.
Semantics:
PUT /lists— full replace: upsert all supplied, DELETE any list not in the payload (cascades its tasks). Idempotent.POST /tasks—listIdmust exist (400/404otherwise). Server generates the id.PUT /tasks/mirror— full replace of theimported=truepartition: upsert every task in the payload (insert withimported=true, or update), and DELETE anyimported=truetask whose id is not in the payload.imported=falserows 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 (
401on bad token); only static assets / login are public. - Validate
listIdon 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:
- API base URL.
- 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.
- 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.