diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..464838b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.nuxt +.output +.git +.data +tests +docs +*.log +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cf1617 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# --- build --- +FROM oven/bun:1.3 AS build +WORKDIR /app +# Install deps first (vendored @kuns/zitadel-auth must be present for the file: dependency). +COPY package.json bun.lock* ./ +COPY vendor ./vendor +RUN bun install --frozen-lockfile +COPY . . +# Public (non-secret) OIDC config is baked into the client bundle at build time. +# Override with --build-arg if the Zitadel app/project ids change. +ARG NUXT_PUBLIC_ZITADEL_ISSUER=https://auth.kuns.dev +ARG NUXT_PUBLIC_ZITADEL_CLIENT_ID=376787352019861775 +ARG NUXT_PUBLIC_ZITADEL_PROJECT_ID=376787351902355727 +ENV NUXT_PUBLIC_ZITADEL_ISSUER=$NUXT_PUBLIC_ZITADEL_ISSUER \ + NUXT_PUBLIC_ZITADEL_CLIENT_ID=$NUXT_PUBLIC_ZITADEL_CLIENT_ID \ + NUXT_PUBLIC_ZITADEL_PROJECT_ID=$NUXT_PUBLIC_ZITADEL_PROJECT_ID +RUN bun run build + +# --- run --- +# Nitro's node-server output is traced for Node's export conditions, so run it on Node +# (not Bun, whose `bun` export condition resolves to files Nitro didn't copy). +FROM node:22-slim AS run +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +# The Nitro output bundles everything it needs (incl. postgres + jose + the migrate plugin). +COPY --from=build /app/.output ./.output +EXPOSE 3000 +CMD ["node", "./.output/server/index.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b57c74d --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# ClaudeDo Online Inbox + +Optional online mirror of the [ClaudeDo](https://github.com/) desktop app's **Idle** task +backlog. Lets the single owner view their lists and jot new tasks from a phone or browser. +The desktop app (local, .NET/SQLite) remains the source of truth; this service only +stores/serves data — it never executes tasks. + +**Live:** https://claudedo.kuns.dev · **API base:** `https://claudedo.kuns.dev/api` + +## Governing rule + +The online store mirrors **exactly** the desktop's Idle tasks. A task exists online only +while it is Idle; queuing it on the desktop deletes it here. Running/Done/Failed/review +tasks never appear online. + +- **Lists** — desktop → online only (full-replace catalog). +- **Idle tasks** — two-way creation: the desktop pushes its Idle tasks (PUT) and pulls + web-created ones (`consumed=false` → import → consume). + +## Stack + +Nuxt 3 (Vue 3, TypeScript, Bun) running as an SPA (`ssr: false`) with Nitro server routes as +the API, on shared PostgreSQL, behind Zitadel auth, deployed via Coolify. + +- Web client login: [`@kuns/zitadel-auth`](../kuns-zitadel) (vendored under `vendor/`). +- API token validation: `jose` against Zitadel JWKS. +- DB access: `postgres` (postgres.js), parameterized queries only. + +## API + +Every `/api/**` route requires a valid Zitadel **access token** (`Authorization: Bearer …`) +belonging to the owner. Missing/invalid/expired → `401`. No anonymous access. + +| Method & path | Caller | Behaviour | +|---|---|---| +| `PUT /api/lists` | desktop | Body `[{id,name}]` = full catalog. Upserts all, deletes lists not in payload (cascades tasks). → 200 | +| `GET /api/lists` | web | → 200 `[{id,name}]` | +| `GET /api/lists/{id}/tasks` | web | → 200 tasks for the list; 404 if unknown | +| `POST /api/tasks` | web | Body `{title, description?, listId}`. Server-generated GUID, `source=web`, `consumed=false`. 404 if listId unknown. → 201 with the created task | +| `PUT /api/tasks/{id}` | desktop | Body `{listId, title, description?}`. Idempotent upsert (`source=desktop` on insert). → 201 (new) / 200 (existing) | +| `DELETE /api/tasks/{id}` | desktop | Idempotent. → 204 (even if absent) | +| `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, createdAt}]` web tasks not yet imported | +| `POST /api/tasks/{id}/consume` | desktop | Sets `consumed=true`. Idempotent. → 200; 404 if unknown | + +Task ids are a **shared GUID space**: web-created ids are reused verbatim by the desktop on +import; all task writes are idempotent upserts keyed on `id`. + +## Zitadel configuration (for the desktop client) + +Both the desktop and the web client authenticate as the **same Zitadel user** (the owner). +Project **ClaudeDo** has two PKCE apps (both issue JWT access tokens): + +| | Web | Desktop | +|---|---|---| +| Client id | `376787352019861775` | `376787352137302287` | +| App type | User-Agent (SPA) | Native | +| Auth method | PKCE (none) | PKCE (none) | +| Grants | authorization_code | authorization_code, refresh_token | +| Redirect | `https://claudedo.kuns.dev/auth/callback` | `http://localhost:8765/callback`, `http://127.0.0.1:8765/callback` | + +**Desktop OnlineInbox settings:** + +- Issuer: `https://auth.kuns.dev` +- Client id: `376787352137302287` +- Scopes: `openid profile email offline_access urn:zitadel:iam:org:project:id:376787351902355727:aud` + - `offline_access` → refresh token for headless re-auth. + - the `…:aud` scope puts the project id into the token's `aud` so the API validates it. +- Flow: Authorization Code + PKCE on a loopback redirect for the initial interactive login, + then refresh-token grant headlessly. Store the refresh token securely. +- API base URL: `https://claudedo.kuns.dev/api` + +Project id: `376787351902355727`. Owner user id (`sub`): `365090688972947729` +(`mika@kuns.dev`). + +## Environment variables + +Server-only values are read from `process.env` at runtime (set them in Coolify): + +| Var | Purpose | +|---|---| +| `DATABASE_URL` | `postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo` (shared PG, internal host) | +| `ZITADEL_ISSUER` | `https://auth.kuns.dev` | +| `ZITADEL_AUDIENCE` | accepted audiences (CSV): web id, desktop id, project id | +| `ALLOWED_USER_IDS` | owner `sub` allowlist (CSV) | +| `WEB_ORIGIN` | CORS allowed origin (`https://claudedo.kuns.dev`) | + +Public web-client config is **baked at build time** (non-secret) via Dockerfile build args +(`NUXT_PUBLIC_ZITADEL_ISSUER`, `NUXT_PUBLIC_ZITADEL_CLIENT_ID`, +`NUXT_PUBLIC_ZITADEL_PROJECT_ID`); override with `--build-arg` if the ids change. + +See `.env.example` for local development. + +## Development + +```bash +bun install +# Point at a local/tunnelled Postgres and apply the schema: +DATABASE_URL=postgres://… bun run migrate +DATABASE_URL=postgres://… bun run dev # http://localhost:3000 + +# Tests (need a Postgres test DB): +DATABASE_URL=postgres://…/claudedo_test bun run test +``` + +Local API smoke without a Zitadel token: set `AUTH_DEV_BYPASS=1` (dev mode only — it is +dead-code-eliminated from production builds). + +## Database + +DB `claudedo` on the shared PostgreSQL instance. Schema (`server/utils/schema.ts`) is applied +idempotently on server startup by `server/plugins/migrate.ts` (advisory-locked) and via +`bun run migrate`. + +## Deployment + +Coolify builds the `Dockerfile` (Bun build → Node runtime) on push to Gitea; Traefik routes +`claudedo.kuns.dev` with automatic SSL. diff --git a/bun.lock b/bun.lock index ffd34dc..61d7978 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "claudedo-online", "dependencies": { - "@kuns/zitadel-auth": "file:../kuns-zitadel/js", + "@kuns/zitadel-auth": "file:./vendor/zitadel-auth", "jose": "^5.9.6", "nuxt": "^4.4.8", "oidc-client-ts": "^3.5.0", @@ -186,7 +186,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kuns/zitadel-auth": ["@kuns/zitadel-auth@file:../kuns-zitadel/js", { "dependencies": { "oidc-client-ts": "^3.5.0" }, "devDependencies": { "@types/react": "^19.2.14", "jsdom": "^29.0.1", "tsup": "^8.0.0", "typescript": "^5.5.0", "vitest": "^3.0.0", "vue": "^3.0.0", "vue-router": "^4.0.0" }, "peerDependencies": { "@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0", "@angular/router": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-router-dom": "^6.0.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0", "vue-router": "^4.0.0" }, "optionalPeers": ["@angular/core", "@angular/router", "react", "react-router-dom", "svelte", "vue", "vue-router"] }], + "@kuns/zitadel-auth": ["@kuns/zitadel-auth@file:vendor/zitadel-auth", { "dependencies": { "oidc-client-ts": "^3.5.0" }, "devDependencies": { "@types/react": "^19.2.14", "jsdom": "^29.0.1", "tsup": "^8.0.0", "typescript": "^5.5.0", "vitest": "^3.0.0", "vue": "^3.0.0", "vue-router": "^4.0.0" }, "peerDependencies": { "@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0", "@angular/router": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-router-dom": "^6.0.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0", "vue-router": "^4.0.0" }, "optionalPeers": ["@angular/core", "@angular/router", "react", "react-router-dom", "svelte", "vue", "vue-router"] }], "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], @@ -638,7 +638,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -1466,6 +1466,8 @@ "@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "@bomb.sh/tab/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "@dxup/nuxt/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1602,9 +1604,9 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index 8163365..5917933 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,12 +4,8 @@ export default defineNuxtConfig({ devtools: { enabled: false }, // Single-user private inbox: no SSR/SEO needs; SPA so @kuns/zitadel-auth runs in-browser. runtimeConfig: { - // server-only - 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 || "", + // Server-only config is read directly from process.env at runtime (see server/utils), + // so it is NOT declared here (Nuxt only overrides runtimeConfig via the NUXT_ prefix). public: { zitadelIssuer: process.env.NUXT_PUBLIC_ZITADEL_ISSUER || "https://auth.kuns.dev", zitadelClientId: process.env.NUXT_PUBLIC_ZITADEL_CLIENT_ID || "", diff --git a/package.json b/package.json index 00593d5..0c7af06 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "provision:zitadel": "tsx scripts/provision-zitadel.ts" }, "dependencies": { - "@kuns/zitadel-auth": "file:../kuns-zitadel/js", + "@kuns/zitadel-auth": "file:./vendor/zitadel-auth", "jose": "^5.9.6", "nuxt": "^4.4.8", "oidc-client-ts": "^3.5.0", diff --git a/server/db/migrate.ts b/server/db/migrate.ts index 1380f00..fb7b830 100644 --- a/server/db/migrate.ts +++ b/server/db/migrate.ts @@ -1,8 +1,7 @@ -// Idempotent migration runner. Run via `bun run migrate` / on container start. -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; +// CLI migration runner (local / CI). Run via `bun run migrate`. +// Production migrations run automatically via server/plugins/migrate.ts on startup. import postgres from "postgres"; +import { INIT_SQL } from "../utils/schema"; const url = process.env.DATABASE_URL; if (!url) { @@ -10,14 +9,10 @@ if (!url) { process.exit(1); } -const here = dirname(fileURLToPath(import.meta.url)); const sql = postgres(url, { max: 1 }); - try { - const ddl = readFileSync(join(here, "migrations", "0001_init.sql"), "utf8"); - // Trusted local DDL file, not user input. - await sql.unsafe(ddl); - console.log("migration 0001_init applied"); + await sql.unsafe(INIT_SQL); + console.log("schema applied"); } finally { await sql.end(); } diff --git a/server/middleware/0.cors.ts b/server/middleware/0.cors.ts index 8738099..75cb214 100644 --- a/server/middleware/0.cors.ts +++ b/server/middleware/0.cors.ts @@ -3,7 +3,7 @@ export default defineEventHandler((event) => { if (!getRequestURL(event).pathname.startsWith("/api/")) return; - const origin = useRuntimeConfig().webOrigin; + const origin = process.env.WEB_ORIGIN || ""; if (origin) { setResponseHeader(event, "Access-Control-Allow-Origin", origin); setResponseHeader(event, "Vary", "Origin"); diff --git a/server/plugins/migrate.ts b/server/plugins/migrate.ts new file mode 100644 index 0000000..04cf68e --- /dev/null +++ b/server/plugins/migrate.ts @@ -0,0 +1,17 @@ +import { INIT_SQL } from "../utils/schema"; + +// Apply the schema idempotently on server startup. An advisory lock prevents concurrent +// instances from racing on CREATE TABLE. Idempotent CREATE ... IF NOT EXISTS means this is +// safe to run on every boot. +export default defineNitroPlugin(async () => { + try { + const sql = getSql(); + await sql.begin(async (tx) => { + await tx`select pg_advisory_xact_lock(871042)`; + await tx.unsafe(INIT_SQL); + }); + console.log("[migrate] schema ensured"); + } catch (e) { + console.error("[migrate] failed:", (e as Error).message); + } +}); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index be339f6..e31829f 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -45,14 +45,13 @@ function splitCsv(v: unknown): string[] { let _cached: ReturnType | null = null; -/** Process-wide verifier built from runtime config (Nitro server context). */ +/** Process-wide verifier built from environment (read at runtime, not baked at build). */ export function getVerifier() { if (!_cached) { - const c = useRuntimeConfig(); _cached = makeVerifier({ - issuer: c.zitadelIssuer, - audiences: splitCsv(c.zitadelAudience), - allowedSubs: splitCsv(c.allowedUserIds), + issuer: process.env.ZITADEL_ISSUER || "https://auth.kuns.dev", + audiences: splitCsv(process.env.ZITADEL_AUDIENCE), + allowedSubs: splitCsv(process.env.ALLOWED_USER_IDS), }); } return _cached; diff --git a/server/db/migrations/0001_init.sql b/server/utils/schema.ts similarity index 79% rename from server/db/migrations/0001_init.sql rename to server/utils/schema.ts index 3bd722d..aef413f 100644 --- a/server/db/migrations/0001_init.sql +++ b/server/utils/schema.ts @@ -1,6 +1,6 @@ --- ClaudeDo Online Inbox — initial schema. --- Mirrors the desktop's Idle task backlog. Idempotent. - +// Canonical schema for the Online Inbox. Single source of truth, applied idempotently by +// the Nitro startup plugin (server/plugins/migrate.ts) and the CLI (server/db/migrate.ts). +export const INIT_SQL = ` create table if not exists lists ( id text primary key, -- GUID supplied by the desktop, reused verbatim name text not null, @@ -20,3 +20,4 @@ create table if not exists tasks ( create index if not exists idx_tasks_list_id on tasks(list_id); create index if not exists idx_tasks_unconsumed on tasks(consumed) where consumed = false; +`; diff --git a/vendor/zitadel-auth/package.json b/vendor/zitadel-auth/package.json new file mode 100644 index 0000000..37a8fbf --- /dev/null +++ b/vendor/zitadel-auth/package.json @@ -0,0 +1,88 @@ +{ + "name": "@kuns/zitadel-auth", + "version": "1.0.0", + "description": "Framework-agnostic Zitadel OIDC auth for kuns.dev frontends", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + }, + "./vue": { + "import": "./dist/vue.js", + "require": "./dist/vue.cjs", + "types": "./dist/vue.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "require": "./dist/react.cjs", + "types": "./dist/react.d.ts" + }, + "./svelte": { + "import": "./dist/svelte.js", + "require": "./dist/svelte.cjs", + "types": "./dist/svelte.d.ts" + }, + "./angular": { + "import": "./dist/angular.js", + "require": "./dist/angular.cjs", + "types": "./dist/angular.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "oidc-client-ts": "^3.5.0" + }, + "peerDependencies": { + "vue": "^3.0.0", + "vue-router": "^4.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-router-dom": "^6.0.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@angular/router": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + }, + "react": { + "optional": true + }, + "react-router-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "@angular/core": { + "optional": true + }, + "@angular/router": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.2.14", + "jsdom": "^29.0.1", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0", + "vue": "^3.0.0", + "vue-router": "^4.0.0" + }, + "author": "kuns.dev", + "license": "MIT" +}