Files
claudedo-online/docs/superpowers/plans/2026-06-10-online-inbox.md

26 KiB

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
{
  "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
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

<template><NuxtPage /></template>
  • 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
import postgres from "postgres";

let sql: ReturnType<typeof postgres> | 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

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
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:

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<ListRow[]> {
  return sql<ListRow[]>`select id, name from lists order by name`;
}

export async function replaceLists(sql: Sql, lists: { id: string; name: string }[]): Promise<void> {
  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<boolean> {
  const r = await sql`select 1 from lists where id = ${id}`;
  return r.length > 0;
}

export async function getTasksForList(sql: Sql, listId: string): Promise<TaskRow[]> {
  return sql<TaskRow[]>`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<TaskRow> {
  const id = randomUUID();
  const [row] = await sql<TaskRow[]>`
    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<TaskRow[]> {
  return sql<TaskRow[]>`select * from tasks where source = 'web' and consumed = false order by created_at`;
}

export async function consume(sql: Sql, id: string): Promise<boolean> {
  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<void> {
  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).
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<string, unknown>) =>
    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

import { createRemoteJWKSet, jwtVerify, type JWTPayload, type KeyLike } from "jose";

export interface VerifierConfig {
  issuer: string;
  audiences: string[];
  allowedSubs: string[];
  keyResolver?: (token: string) => Promise<KeyLike | Uint8Array>; // 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<JWTPayload> {
    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<typeof makeVerifier> | 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:

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
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.tsreturn getLists(getSql())

  • Step 3: lists/[id]/tasks.get.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)
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)
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)
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)
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)
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)

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.
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):
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 clientIds + 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)
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.