diff --git a/docs/superpowers/plans/2026-06-10-online-inbox.md b/docs/superpowers/plans/2026-06-10-online-inbox.md
new file mode 100644
index 0000000..b8b2255
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-10-online-inbox.md
@@ -0,0 +1,657 @@
+# ClaudeDo Online Inbox Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Nuxt 3 (TS/Bun) service at `claudedo.kuns.dev` that mirrors the desktop's Idle task backlog: a Zitadel-gated `/api/**` over shared Postgres + a mobile-first create/read web client, deployed via Coolify.
+
+**Architecture:** Single Nuxt app, `ssr: false` (SPA) so `@kuns/zitadel-auth/vue` runs in the browser; Nitro serves `/api/**`. A thin repository layer (`server/db/repo.ts`) holds all parameterized SQL (postgres.js). Server middleware verifies Zitadel access tokens with `jose` JWKS and enforces an owner allowlist. The desktop pushes lists (full-replace) + Idle tasks (idempotent upsert) and pulls/consumes web-created tasks.
+
+**Tech Stack:** Nuxt 3, Vue 3, Nitro, TypeScript, Bun, `postgres` (postgres.js), `jose`, `@kuns/zitadel-auth`, Vitest, Docker/Coolify.
+
+---
+
+## File Structure
+
+```
+nuxt.config.ts — ssr:false, runtimeConfig, app meta
+package.json — deps, scripts (bun)
+Dockerfile — bun build → node-server output
+.env.example — documented env vars
+server/
+ utils/db.ts — postgres.js singleton (DATABASE_URL)
+ utils/auth.ts — jose JWKS verify + owner allowlist (pure, testable)
+ db/migrations/0001_init.sql — lists + tasks tables
+ db/migrate.ts — idempotent migration runner (run at container start)
+ db/repo.ts — all SQL: lists + tasks (parameterized)
+ middleware/0.cors.ts — CORS for /api (allow WEB_ORIGIN)
+ middleware/1.auth.ts — 401 unless valid owner token, gates /api/**
+ api/lists.put.ts — full-replace catalog
+ api/lists.get.ts
+ api/lists/[id]/tasks.get.ts
+ api/tasks.post.ts — web create (server GUID)
+ api/tasks.get.ts — ?consumed=false
+ api/tasks/[id].put.ts — desktop upsert
+ api/tasks/[id].delete.ts
+ api/tasks/[id]/consume.post.ts
+app.vue — router-view + auth bootstrap
+plugins/auth.client.ts — wire @kuns/zitadel-auth/vue to Nuxt router
+composables/useAuth.ts — expose auth singleton + authed $fetch
+pages/index.vue — lists + selected-list tasks + add form
+pages/auth/callback.vue — OIDC callback landing
+tests/repo.test.ts — repository TDD (test DB)
+tests/auth.test.ts — token verify unit tests
+scripts/provision-zitadel.ts — create ClaudeDo project + 2 PKCE apps
+README.md
+```
+
+---
+
+## Task 1: Scaffold Nuxt project
+
+**Files:** Create `package.json`, `nuxt.config.ts`, `tsconfig.json`, `.env.example`, `app.vue`
+
+- [ ] **Step 1: package.json**
+
+```json
+{
+ "name": "claudedo-online",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "nuxt dev",
+ "build": "nuxt build",
+ "preview": "node .output/server/index.mjs",
+ "migrate": "tsx server/db/migrate.ts",
+ "test": "vitest run",
+ "provision:zitadel": "tsx scripts/provision-zitadel.ts"
+ },
+ "dependencies": {
+ "@kuns/zitadel-auth": "file:../kuns-zitadel/js",
+ "jose": "^5.9.6",
+ "nuxt": "^3.15.0",
+ "oidc-client-ts": "^3.5.0",
+ "postgres": "^3.4.8",
+ "vue": "^3.5.0",
+ "vue-router": "^4.4.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.21.0",
+ "vitest": "^2.1.0"
+ }
+}
+```
+
+Note: confirm `@kuns/zitadel-auth` resolves from the sibling repo. If `file:` linking is awkward in the Docker build, vendor the built `dist` or publish to the Gitea npm registry; decide in Task 9/10.
+
+- [ ] **Step 2: nuxt.config.ts**
+
+```ts
+export default defineNuxtConfig({
+ ssr: false,
+ devtools: { enabled: false },
+ runtimeConfig: {
+ databaseUrl: process.env.DATABASE_URL,
+ zitadelIssuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev",
+ zitadelAudience: process.env.ZITADEL_AUDIENCE || "",
+ allowedUserIds: process.env.ALLOWED_USER_IDS || "",
+ webOrigin: process.env.WEB_ORIGIN || "",
+ public: {
+ zitadelIssuer: process.env.NUXT_PUBLIC_ZITADEL_ISSUER || "https://auth.kuns.dev",
+ zitadelClientId: process.env.NUXT_PUBLIC_ZITADEL_CLIENT_ID || "",
+ },
+ },
+});
+```
+
+- [ ] **Step 3: tsconfig.json** → `{ "extends": "./.nuxt/tsconfig.json" }`
+
+- [ ] **Step 4: app.vue**
+
+```vue
+
+```
+
+- [ ] **Step 5: .env.example** — document every var from the spec's env table.
+
+- [ ] **Step 6: Commit** `feat: scaffold nuxt app`
+
+---
+
+## Task 2: DB connection, migration SQL + runner
+
+**Files:** Create `server/utils/db.ts`, `server/db/migrations/0001_init.sql`, `server/db/migrate.ts`
+
+- [ ] **Step 1: server/utils/db.ts**
+
+```ts
+import postgres from "postgres";
+
+let sql: ReturnType | null = null;
+
+export function getSql() {
+ if (!sql) {
+ const url = process.env.DATABASE_URL;
+ if (!url) throw new Error("DATABASE_URL not set");
+ sql = postgres(url, { max: 5, idle_timeout: 30 });
+ }
+ return sql;
+}
+```
+
+- [ ] **Step 2: server/db/migrations/0001_init.sql** — the two `create table if not exists` + indexes from the spec.
+
+- [ ] **Step 3: server/db/migrate.ts**
+
+```ts
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import postgres from "postgres";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
+const ddl = readFileSync(join(here, "migrations", "0001_init.sql"), "utf8");
+await sql.unsafe(ddl); // trusted local DDL file, not user input
+console.log("migration applied");
+await sql.end();
+```
+
+- [ ] **Step 4: Commit** `feat: db connection and migration`
+
+---
+
+## Task 3: Repository layer (TDD against test DB)
+
+**Files:** Create `server/db/repo.ts`, `tests/repo.test.ts`
+
+Test DB: `claudedo_test` on the shared Postgres (created in Task 9 / locally). Tests set `DATABASE_URL` to it and truncate between tests.
+
+- [ ] **Step 1: Write failing tests** `tests/repo.test.ts`
+
+```ts
+import { beforeEach, describe, expect, it } from "vitest";
+import postgres from "postgres";
+import * as repo from "../server/db/repo";
+
+const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
+
+beforeEach(async () => {
+ await sql`truncate lists cascade`;
+});
+
+describe("lists", () => {
+ it("full-replace upserts and deletes missing", async () => {
+ await repo.replaceLists(sql, [{ id: "a", name: "A" }, { id: "b", name: "B" }]);
+ await repo.replaceLists(sql, [{ id: "a", name: "A2" }]); // b removed
+ const got = await repo.getLists(sql);
+ expect(got).toEqual([{ id: "a", name: "A2" }]);
+ });
+
+ it("deleting a list cascades its tasks", async () => {
+ await repo.replaceLists(sql, [{ id: "a", name: "A" }]);
+ await repo.upsertDesktopTask(sql, "t1", { listId: "a", title: "x" });
+ await repo.replaceLists(sql, []); // removes a + cascades t1
+ expect(await repo.getUnconsumed(sql)).toEqual([]);
+ });
+});
+
+describe("tasks", () => {
+ beforeEach(async () => {
+ await repo.replaceLists(sql, [{ id: "L", name: "List" }]);
+ });
+
+ it("web create returns generated id, source web, consumed false", async () => {
+ const t = await repo.createWebTask(sql, { listId: "L", title: "buy milk", description: null });
+ expect(t.id).toMatch(/[0-9a-f-]{36}/);
+ expect(t.source).toBe("web");
+ expect(t.consumed).toBe(false);
+ });
+
+ it("desktop upsert is idempotent by id and inserts source desktop", async () => {
+ const a = await repo.upsertDesktopTask(sql, "fixed-id", { listId: "L", title: "one" });
+ expect(a.created).toBe(true);
+ const b = await repo.upsertDesktopTask(sql, "fixed-id", { listId: "L", title: "two" });
+ expect(b.created).toBe(false);
+ const tasks = await repo.getTasksForList(sql, "L");
+ expect(tasks).toHaveLength(1);
+ expect(tasks[0].title).toBe("two");
+ expect(tasks[0].source).toBe("desktop");
+ });
+
+ it("getUnconsumed returns only web tasks with consumed=false", async () => {
+ await repo.createWebTask(sql, { listId: "L", title: "web1", description: null });
+ await repo.upsertDesktopTask(sql, "d1", { listId: "L", title: "desk1" });
+ const u = await repo.getUnconsumed(sql);
+ expect(u).toHaveLength(1);
+ expect(u[0].title).toBe("web1");
+ });
+
+ it("consume sets consumed true; delete is idempotent", async () => {
+ const t = await repo.createWebTask(sql, { listId: "L", title: "c", description: null });
+ expect(await repo.consume(sql, t.id)).toBe(true);
+ expect(await repo.getUnconsumed(sql)).toEqual([]);
+ expect(await repo.consume(sql, "nope")).toBe(false);
+ await repo.deleteTask(sql, t.id);
+ await repo.deleteTask(sql, t.id); // no throw
+ });
+
+ it("listExists validates listId", async () => {
+ expect(await repo.listExists(sql, "L")).toBe(true);
+ expect(await repo.listExists(sql, "ghost")).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run, expect fail** `bun run test` → repo functions undefined.
+
+- [ ] **Step 3: Implement server/db/repo.ts** — typed functions; every query a parameterized tagged template. Signatures:
+
+```ts
+import type { Sql } from "postgres";
+import { randomUUID } from "node:crypto";
+
+export interface ListRow { id: string; name: string; }
+export interface TaskRow {
+ id: string; list_id: string; title: string; description: string | null;
+ source: string; consumed: boolean; created_at: string; updated_at: string;
+}
+
+export async function getLists(sql: Sql): Promise {
+ return sql`select id, name from lists order by name`;
+}
+
+export async function replaceLists(sql: Sql, lists: { id: string; name: string }[]): Promise {
+ await sql.begin(async (tx) => {
+ const ids = lists.map((l) => l.id);
+ for (const l of lists) {
+ await tx`insert into lists (id, name, updated_at) values (${l.id}, ${l.name}, now())
+ on conflict (id) do update set name = excluded.name, updated_at = now()`;
+ }
+ if (ids.length) await tx`delete from lists where id not in ${tx(ids)}`;
+ else await tx`delete from lists`;
+ });
+}
+
+export async function listExists(sql: Sql, id: string): Promise {
+ const r = await sql`select 1 from lists where id = ${id}`;
+ return r.length > 0;
+}
+
+export async function getTasksForList(sql: Sql, listId: string): Promise {
+ return sql`select * from tasks where list_id = ${listId} order by created_at`;
+}
+
+export async function createWebTask(sql: Sql, t: { listId: string; title: string; description: string | null }): Promise {
+ const id = randomUUID();
+ const [row] = await sql`
+ insert into tasks (id, list_id, title, description, source, consumed)
+ values (${id}, ${t.listId}, ${t.title}, ${t.description}, 'web', false)
+ returning *`;
+ return row;
+}
+
+export async function upsertDesktopTask(sql: Sql, id: string, t: { listId: string; title: string; description?: string | null }): Promise<{ created: boolean }> {
+ const [row] = await sql<{ created: boolean }[]>`
+ insert into tasks (id, list_id, title, description, source)
+ values (${id}, ${t.listId}, ${t.title}, ${t.description ?? null}, 'desktop')
+ on conflict (id) do update set
+ list_id = excluded.list_id, title = excluded.title,
+ description = excluded.description, updated_at = now()
+ returning (xmax = 0) as created`;
+ return { created: row.created };
+}
+
+export async function getUnconsumed(sql: Sql): Promise {
+ return sql`select * from tasks where source = 'web' and consumed = false order by created_at`;
+}
+
+export async function consume(sql: Sql, id: string): Promise {
+ const r = await sql`update tasks set consumed = true, updated_at = now() where id = ${id}`;
+ return r.count > 0;
+}
+
+export async function deleteTask(sql: Sql, id: string): Promise {
+ await sql`delete from tasks where id = ${id}`;
+}
+```
+
+- [ ] **Step 4: Run, expect pass** `bun run test`
+- [ ] **Step 5: Commit** `feat: repository layer with tests`
+
+---
+
+## Task 4: Auth — token verification + middleware (TDD the pure part)
+
+**Files:** Create `server/utils/auth.ts`, `tests/auth.test.ts`, `server/middleware/1.auth.ts`
+
+- [ ] **Step 1: tests/auth.test.ts** — unit-test allowlist + audience logic with a locally-signed JWT and an injected JWKS (use `jose` `generateKeyPair` + `SignJWT`, and a `verifyAccessToken` that accepts a key resolver for testability).
+
+```ts
+import { describe, expect, it } from "vitest";
+import { SignJWT, generateKeyPair } from "jose";
+import { makeVerifier } from "../server/utils/auth";
+
+const ISS = "https://auth.kuns.dev";
+
+async function setup() {
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
+ const verify = makeVerifier({
+ issuer: ISS, audiences: ["aud-web", "proj-1"], allowedSubs: ["owner-1"],
+ keyResolver: async () => publicKey,
+ });
+ const sign = (claims: Record) =>
+ new SignJWT(claims).setProtectedHeader({ alg: "RS256" })
+ .setIssuer(ISS).setExpirationTime("5m").sign(privateKey);
+ return { verify, sign };
+}
+
+it("accepts owner token with valid audience", async () => {
+ const { verify, sign } = await setup();
+ const t = await sign({ sub: "owner-1", aud: ["aud-web"] });
+ await expect(verify(t)).resolves.toMatchObject({ sub: "owner-1" });
+});
+
+it("rejects non-owner sub", async () => {
+ const { verify, sign } = await setup();
+ const t = await sign({ sub: "intruder", aud: ["aud-web"] });
+ await expect(verify(t)).rejects.toThrow();
+});
+
+it("rejects wrong audience", async () => {
+ const { verify, sign } = await setup();
+ const t = await sign({ sub: "owner-1", aud: ["other"] });
+ await expect(verify(t)).rejects.toThrow();
+});
+```
+
+- [ ] **Step 2: Run, expect fail.**
+
+- [ ] **Step 3: server/utils/auth.ts**
+
+```ts
+import { createRemoteJWKSet, jwtVerify, type JWTPayload, type KeyLike } from "jose";
+
+export interface VerifierConfig {
+ issuer: string;
+ audiences: string[];
+ allowedSubs: string[];
+ keyResolver?: (token: string) => Promise; // test seam
+}
+
+export function makeVerifier(cfg: VerifierConfig) {
+ const jwks = cfg.keyResolver
+ ? undefined
+ : createRemoteJWKSet(new URL(`${cfg.issuer}/oauth/v2/keys`));
+ return async function verify(token: string): Promise {
+ const key = cfg.keyResolver ? await cfg.keyResolver(token) : jwks!;
+ const { payload } = await jwtVerify(token, key as any, { issuer: cfg.issuer });
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
+ if (!cfg.audiences.some((a) => aud.includes(a))) throw new Error("bad audience");
+ if (!cfg.allowedSubs.includes(String(payload.sub))) throw new Error("not owner");
+ return payload;
+ };
+}
+
+let cached: ReturnType | null = null;
+export function getVerifier() {
+ if (!cached) {
+ const c = useRuntimeConfig();
+ cached = makeVerifier({
+ issuer: c.zitadelIssuer,
+ audiences: String(c.zitadelAudience).split(",").map((s) => s.trim()).filter(Boolean),
+ allowedSubs: String(c.allowedUserIds).split(",").map((s) => s.trim()).filter(Boolean),
+ });
+ }
+ return cached;
+}
+```
+
+- [ ] **Step 4: Run, expect pass.**
+
+- [ ] **Step 5: server/middleware/1.auth.ts** — gate `/api/**` only:
+
+```ts
+export default defineEventHandler(async (event) => {
+ const path = getRequestURL(event).pathname;
+ if (!path.startsWith("/api/")) return;
+ const auth = getHeader(event, "authorization") || "";
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
+ if (!token) throw createError({ statusCode: 401, statusMessage: "missing token" });
+ try {
+ event.context.user = await getVerifier()(token);
+ } catch {
+ throw createError({ statusCode: 401, statusMessage: "invalid token" });
+ }
+});
+```
+
+- [ ] **Step 6: Commit** `feat: zitadel token auth middleware`
+
+---
+
+## Task 5: List endpoints
+
+**Files:** Create `server/api/lists.put.ts`, `server/api/lists.get.ts`, `server/api/lists/[id]/tasks.get.ts`
+
+- [ ] **Step 1: lists.put.ts**
+
+```ts
+import { getSql } from "~/server/utils/db";
+import { replaceLists } from "~/server/db/repo";
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event);
+ if (!Array.isArray(body) || body.some((l) => typeof l?.id !== "string" || typeof l?.name !== "string"))
+ throw createError({ statusCode: 400, statusMessage: "expected [{id,name}]" });
+ await replaceLists(getSql(), body.map((l) => ({ id: l.id, name: l.name })));
+ return { ok: true };
+});
+```
+
+- [ ] **Step 2: lists.get.ts** → `return getLists(getSql())`
+
+- [ ] **Step 3: lists/[id]/tasks.get.ts**
+
+```ts
+export default defineEventHandler(async (event) => {
+ const id = getRouterParam(event, "id")!;
+ const sql = getSql();
+ if (!(await listExists(sql, id))) throw createError({ statusCode: 404, statusMessage: "list not found" });
+ const rows = await getTasksForList(sql, id);
+ return rows.map(toTaskDto);
+});
+```
+
+where `toTaskDto` (define in `server/utils/dto.ts`) maps snake_case row → `{ id, listId, title, description, source, consumed, createdAt }`.
+
+- [ ] **Step 4: Manual smoke** via `bun run dev` + curl with a real token (after Task 9). Commit `feat: list endpoints`.
+
+---
+
+## Task 6: Task endpoints
+
+**Files:** Create `server/api/tasks.post.ts`, `server/api/tasks.get.ts`, `server/api/tasks/[id].put.ts`, `server/api/tasks/[id].delete.ts`, `server/api/tasks/[id]/consume.post.ts`
+
+- [ ] **Step 1: tasks.post.ts** (web create, 201)
+
+```ts
+export default defineEventHandler(async (event) => {
+ const b = await readBody(event);
+ if (typeof b?.title !== "string" || !b.title.trim() || typeof b?.listId !== "string")
+ throw createError({ statusCode: 400, statusMessage: "title and listId required" });
+ const sql = getSql();
+ if (!(await listExists(sql, b.listId))) throw createError({ statusCode: 404, statusMessage: "list not found" });
+ const row = await createWebTask(sql, { listId: b.listId, title: b.title, description: b.description ?? null });
+ setResponseStatus(event, 201);
+ return toTaskDto(row);
+});
+```
+
+- [ ] **Step 2: tasks.get.ts** (`?consumed=false` → unconsumed web tasks)
+
+```ts
+export default defineEventHandler(async () => {
+ const rows = await getUnconsumed(getSql());
+ return rows.map((r) => ({ id: r.id, listId: r.list_id, title: r.title, description: r.description, createdAt: r.created_at }));
+});
+```
+
+- [ ] **Step 3: tasks/[id].put.ts** (desktop upsert; 201 if created else 200)
+
+```ts
+export default defineEventHandler(async (event) => {
+ const id = getRouterParam(event, "id")!;
+ const b = await readBody(event);
+ if (typeof b?.title !== "string" || typeof b?.listId !== "string")
+ throw createError({ statusCode: 400, statusMessage: "listId and title required" });
+ const sql = getSql();
+ if (!(await listExists(sql, b.listId))) throw createError({ statusCode: 404, statusMessage: "list not found" });
+ const { created } = await upsertDesktopTask(sql, id, { listId: b.listId, title: b.title, description: b.description ?? null });
+ setResponseStatus(event, created ? 201 : 200);
+ return { id };
+});
+```
+
+- [ ] **Step 4: tasks/[id].delete.ts** (idempotent 204)
+
+```ts
+export default defineEventHandler(async (event) => {
+ await deleteTask(getSql(), getRouterParam(event, "id")!);
+ setResponseStatus(event, 204);
+ return null;
+});
+```
+
+- [ ] **Step 5: tasks/[id]/consume.post.ts** (200, 404 if unknown)
+
+```ts
+export default defineEventHandler(async (event) => {
+ const ok = await consume(getSql(), getRouterParam(event, "id")!);
+ if (!ok) throw createError({ statusCode: 404, statusMessage: "task not found" });
+ return { ok: true };
+});
+```
+
+- [ ] **Step 6: Commit** `feat: task endpoints`
+
+---
+
+## Task 7: CORS middleware
+
+**Files:** Create `server/middleware/0.cors.ts` (runs before auth; handles preflight)
+
+```ts
+export default defineEventHandler((event) => {
+ if (!getRequestURL(event).pathname.startsWith("/api/")) return;
+ const origin = useRuntimeConfig().webOrigin;
+ if (origin) {
+ setResponseHeader(event, "Access-Control-Allow-Origin", origin);
+ setResponseHeader(event, "Vary", "Origin");
+ setResponseHeader(event, "Access-Control-Allow-Headers", "authorization, content-type");
+ setResponseHeader(event, "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+ }
+ if (event.method === "OPTIONS") { setResponseStatus(event, 204); return ""; }
+});
+```
+
+Commit `feat: cors for api`.
+
+---
+
+## Task 8: Web client (mobile-first, create + read)
+
+**Files:** Create `plugins/auth.client.ts`, `composables/useAuth.ts`, `pages/index.vue`, `pages/auth/callback.vue`
+
+- [ ] **Step 1: plugins/auth.client.ts** — instantiate `useZitadelAuth(router, {clientId, issuer})` from `@kuns/zitadel-auth/vue`, provide it app-wide.
+
+```ts
+import { useZitadelAuth } from "@kuns/zitadel-auth/vue";
+export default defineNuxtPlugin((nuxt) => {
+ const cfg = useRuntimeConfig().public;
+ const auth = useZitadelAuth(useRouter() as any, {
+ clientId: cfg.zitadelClientId, issuer: cfg.zitadelIssuer,
+ });
+ return { provide: { auth } };
+});
+```
+
+- [ ] **Step 2: composables/useAuth.ts** — expose `$auth` and an `api(path, init)` helper that uses `auth.fetch` (bearer auto-attached) against `/api`.
+
+- [ ] **Step 3: pages/index.vue** — mobile-first: header w/ user + logout; left/top a list selector (`GET /api/lists`); on select, `GET /api/lists/:id/tasks` shows Idle tasks (title + description); an "add task" form (title required, description optional) → `POST /api/tasks` then refresh. Use `frontend-design` aesthetic: clean, large tap targets, no clutter. Read+create only — no edit/delete/reorder controls.
+
+- [ ] **Step 4: pages/auth/callback.vue** — minimal; the package handles the callback in `init()`, just show "Signing in…".
+
+- [ ] **Step 5: Verify** with `bun run dev`, log in, add a task, see it appear. Commit `feat: web client`.
+
+---
+
+## Task 9: Provision Postgres DB + Zitadel apps
+
+**Files:** Create `scripts/provision-zitadel.ts`
+
+- [ ] **Step 1: Create DBs** on shared Postgres (container `l8kogcggsc80sgcgk8kswww4`, user `mika`, pw from `~/.secrets/coolify-tokens.env`):
+
+```bash
+source ~/.secrets/coolify-tokens.env
+docker exec l8kogcggsc80sgcgk8kswww4 psql -U mika -d main -c "CREATE DATABASE claudedo OWNER mika;"
+docker exec l8kogcggsc80sgcgk8kswww4 psql -U mika -d main -c "CREATE DATABASE claudedo_test OWNER mika;"
+```
+
+- [ ] **Step 2: Run migration** against both (set DATABASE_URL via SSH tunnel or run inside the docker network). Run tests (Task 3) against `claudedo_test`.
+
+- [ ] **Step 3: scripts/provision-zitadel.ts** — using `ZITADEL_SERVICE_TOKEN` (PAT) against `https://auth.kuns.dev/management/v1`:
+ 1. `POST /projects` → `{ name: "ClaudeDo" }` → projectId.
+ 2. `POST /projects/{projectId}/apps/oidc` for **ClaudeDo Web**: `appType: OIDC_APP_TYPE_USER_AGENT`, `authMethodType: OIDC_AUTH_METHOD_TYPE_NONE` (PKCE), `responseTypes:[CODE]`, `grantTypes:[AUTHORIZATION_CODE]`, `redirectUris:["https://claudedo.kuns.dev/auth/callback"]`, `postLogoutRedirectUris:["https://claudedo.kuns.dev"]`, `accessTokenType: OIDC_TOKEN_TYPE_JWT`, `accessTokenRoleAssertion: true`, `devMode:false`.
+ 3. `POST /projects/{projectId}/apps/oidc` for **ClaudeDo Desktop**: `appType: OIDC_APP_TYPE_NATIVE`, `authMethodType: NONE`, `responseTypes:[CODE]`, `grantTypes:[AUTHORIZATION_CODE, REFRESH_TOKEN]`, `redirectUris:["http://localhost:8765/callback"]` (loopback; desktop may use any localhost port — register a couple), `accessTokenType: JWT`.
+ 4. Print both `clientId`s + `projectId`. (Token type JWT means access tokens are verifiable via JWKS.)
+ 5. Resolve owner `sub`: `GET /management/v1/users/me` or list users → owner user id for `ALLOWED_USER_IDS`.
+
+- [ ] **Step 4:** Record `ZITADEL_CLIENT_CLAUDEDO_WEB_ID`, `..._DESKTOP_ID`, `..._PROJECT_ID`, owner sub into `~/.secrets/coolify-tokens.env`. Commit `feat: zitadel provisioning script` (no secrets committed).
+
+Verify exact Management API field names/enums against current Zitadel docs (context7 / `auth.kuns.dev` OpenAPI) before running — v2.71.6.
+
+---
+
+## Task 10: Dockerfile, Coolify deploy, README, report
+
+**Files:** Create `Dockerfile`, `README.md`
+
+- [ ] **Step 1: Dockerfile** (Bun build → run node-server output; run migration on start)
+
+```dockerfile
+FROM oven/bun:1.3 AS build
+WORKDIR /app
+COPY package.json bun.lock* ./
+# kuns-zitadel/js must be available at build context or pre-built/vendored
+COPY . .
+RUN bun install
+RUN bun run build
+
+FROM oven/bun:1.3 AS run
+WORKDIR /app
+COPY --from=build /app/.output ./.output
+COPY --from=build /app/server/db ./server/db
+COPY --from=build /app/node_modules ./node_modules
+COPY --from=build /app/package.json ./
+ENV PORT=3000
+EXPOSE 3000
+CMD ["sh", "-c", "bun run server/db/migrate.ts && node .output/server/index.mjs"]
+```
+
+Resolve the `@kuns/zitadel-auth` `file:` dependency for the Docker build (vendor built `dist/` into the repo, or publish to Gitea npm registry). Decide and document.
+
+- [ ] **Step 2: Deploy** — create Gitea repo + Coolify app via `deploy claudedo-online claudedo 3000` (per global deploy scripts), set env vars in Coolify (DATABASE_URL with internal host, ZITADEL_*, ALLOWED_USER_IDS, WEB_ORIGIN, NUXT_PUBLIC_*), push → build → Traefik routes `claudedo.kuns.dev`.
+
+- [ ] **Step 3: Smoke test deployed** — 401 without token; with a logged-in web session: list/create works; desktop flow (PUT lists, PUT task, GET ?consumed=false, consume, delete) via curl + token.
+
+- [ ] **Step 4: README.md** — covers API base URL, endpoints, the Zitadel config the desktop must use (issuer, client id, scopes incl. `openid profile email offline_access`, redirect/refresh), and all env vars.
+
+- [ ] **Step 5: Final report** to user: (1) API base URL `https://claudedo.kuns.dev/api`, (2) desktop Zitadel config, (3) env vars.
+
+---
+
+## Self-Review
+
+- **Spec coverage:** lists (PUT/GET) ✓ T5; list tasks GET ✓ T5; tasks POST/PUT/DELETE/GET?consumed/consume ✓ T6; shared GUID + idempotent upsert ✓ T3; cascade ✓ T3; auth 401 ✓ T4; CORS ✓ T7; param queries ✓ T3; no info-logging of content ✓ (no logging added); web client create+read ✓ T8; migration ✓ T2; Zitadel provision ✓ T9; Coolify deploy + report ✓ T10.
+- **Placeholders:** loopback port `8765` and `{projectId}` are filled at provision time (T9), not plan gaps. Docker `file:` dep resolution flagged with concrete options.
+- **Type consistency:** repo row type `TaskRow` (snake_case) → `toTaskDto` (camelCase) used consistently in T5/T6; `upsertDesktopTask` returns `{created}` used in T6 Step 3.