Files
claudedo-online/docs/superpowers/plans/2026-06-11-owner-scoping.md

1127 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Per-User Data Isolation (ownerId Scoping) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Scope every read and write in the ClaudeDo Online API to the authenticated token's `sub` (server-side ownership boundary), expose `ownerId` in all DTOs, and make the FE filter by owner and surface a clear "missing 'user' role" state.
**Architecture:** Add a nullable `owner_id text` column to `lists` and `tasks`. Every repo function takes the caller's `ownerId` (= Zitadel `sub`, taken from the verified token in `event.context.user`, never from the client). Reads return rows where `owner_id = caller OR owner_id IS NULL` (NULL = legacy/pre-multi-user, visible per the desktop handoff contract). Writes stamp `owner_id = caller`; upserts adopt legacy rows but are blocked (`ON CONFLICT … WHERE`) from touching rows owned by someone else. Full-replace deletes (`PUT /lists`, `PUT /tasks/mirror`) are scoped to the caller's partition so one user's sync can never wipe another's data. The role gate (commit `d4c7347`) already exists and is untouched.
**Tech Stack:** Nuxt 4 / Nitro, postgres.js, Vitest, Bun. Tests run against the real `claudedo_test` DB.
**Already done (do NOT re-implement):** role-based access in `server/middleware/1.auth.ts` + `server/utils/auth.ts` (items 12 of the handoff brief). This plan covers items 34 (ownerId plumbing + FE) and the security-prerequisite scoping.
---
## Environment setup (needed by Tasks 1, 2, 6)
All test/migrate commands need the shared-Postgres password and the test DB URL:
```bash
source ~/.secrets/coolify-tokens.env
export DATABASE_URL="postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/claudedo_test"
```
(`localhost:54320` is the Coolify proxy in front of the shared Postgres container `l8kogcggsc80sgcgk8kswww4`. Verified reachable from this host.)
Run everything with Bun: `~/.bun/bin/bun` if `bun` is not in PATH.
---
### Task 1: Schema — `owner_id` columns
**Files:**
- Modify: `server/utils/schema.ts`
- [ ] **Step 1: Append owner_id DDL to INIT_SQL**
In `server/utils/schema.ts`, the template string ends with:
```
create index if not exists idx_tasks_unconsumed on tasks(consumed) where consumed = false;
`;
```
Add the two `alter table` lines before the closing backtick, so the end of `INIT_SQL` reads:
```ts
create index if not exists idx_tasks_unconsumed on tasks(consumed) where consumed = false;
-- Multi-user plumbing: rows are owned by a Zitadel sub. NULL = legacy/unowned (pre-multi-user).
alter table lists add column if not exists owner_id text;
alter table tasks add column if not exists owner_id text;
`;
```
Do NOT add `owner_id` to the `create table` statements — production tables already exist, so `create table if not exists` is a no-op there; the `alter table … if not exists` lines are the single mechanism that works on both fresh and existing databases.
- [ ] **Step 2: Apply to the test DB and verify**
```bash
source ~/.secrets/coolify-tokens.env
export DATABASE_URL="postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/claudedo_test"
bun run migrate
psql "$DATABASE_URL" -c "\d lists" -c "\d tasks" | grep owner_id
```
Expected: `schema applied`, then two `owner_id | text` lines.
- [ ] **Step 3: Run the existing test suite to confirm nothing broke**
```bash
bun run test
```
Expected: all existing tests PASS (the new column is nullable; nothing references it yet).
- [ ] **Step 4: Commit**
```bash
git add server/utils/schema.ts
git commit -m "feat: add nullable owner_id columns to lists and tasks"
```
---
### Task 2: Repo layer — owner scoping (TDD)
**Files:**
- Modify: `server/utils/repo.ts` (full rewrite below)
- Test: `tests/repo.test.ts` (full rewrite below)
Every repo function gains an `ownerId: string` parameter (always the second argument, after `sql`). Scoping rule used everywhere: a row is *mine* if `owner_id = ${ownerId} OR owner_id IS NULL`.
- [ ] **Step 1: Replace `tests/repo.test.ts` with the owner-aware suite**
This updates every existing call site to pass an owner (`"u1"`) — preserving all prior behavioral assertions — and adds an `owner isolation` describe block. Full file content:
```ts
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import * as repo from "../server/utils/repo";
const sql = postgres(process.env.DATABASE_URL!, { max: 1, onnotice: () => {} });
const U1 = "user-1";
const U2 = "user-2";
beforeEach(async () => {
await sql`truncate lists cascade`;
});
afterAll(async () => {
await sql.end();
});
describe("lists", () => {
it("full-replace upserts supplied and deletes missing", async () => {
await repo.replaceLists(sql, U1, [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
]);
await repo.replaceLists(sql, U1, [{ id: "a", name: "A2" }]); // b removed, a renamed
const got = await repo.getLists(sql, U1);
expect(got).toEqual([{ id: "a", name: "A2", owner_id: U1 }]);
});
it("empty payload clears all my lists", async () => {
await repo.replaceLists(sql, U1, [{ id: "a", name: "A" }]);
await repo.replaceLists(sql, U1, []);
expect(await repo.getLists(sql, U1)).toEqual([]);
});
it("deleting a list cascades its tasks", async () => {
await repo.replaceLists(sql, U1, [{ id: "a", name: "A" }]);
await repo.upsertDesktopTask(sql, U1, "t1", { listId: "a", title: "x" });
await repo.replaceLists(sql, U1, []); // removes a + cascades t1
expect(await repo.getUnconsumed(sql, U1)).toEqual([]);
});
it("listExists validates listId within the caller's scope", async () => {
await repo.replaceLists(sql, U1, [{ id: "L", name: "List" }]);
expect(await repo.listExists(sql, U1, "L")).toBe(true);
expect(await repo.listExists(sql, U1, "ghost")).toBe(false);
expect(await repo.listExists(sql, U2, "L")).toBe(false); // not my list
});
});
describe("tasks", () => {
beforeEach(async () => {
await repo.replaceLists(sql, U1, [{ id: "L", name: "List" }]);
});
it("web create returns generated GUID, source=web, consumed=false, stamped owner", async () => {
const t = await repo.createWebTask(sql, U1, { listId: "L", title: "buy milk", description: null });
expect(t.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(t.source).toBe("web");
expect(t.consumed).toBe(false);
expect(t.title).toBe("buy milk");
expect(t.owner_id).toBe(U1);
});
it("desktop upsert is idempotent by id; source=desktop on insert", async () => {
const a = await repo.upsertDesktopTask(sql, U1, "fixed-id", { listId: "L", title: "one" });
expect(a.created).toBe(true);
const b = await repo.upsertDesktopTask(sql, U1, "fixed-id", { listId: "L", title: "two", description: "d" });
expect(b.created).toBe(false);
const tasks = await repo.getTasksForList(sql, U1, "L");
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("two");
expect(tasks[0].description).toBe("d");
expect(tasks[0].source).toBe("desktop");
});
it("getUnconsumed returns only web tasks with consumed=false", async () => {
await repo.createWebTask(sql, U1, { listId: "L", title: "web1", description: null });
await repo.upsertDesktopTask(sql, U1, "d1", { listId: "L", title: "desk1" });
const u = await repo.getUnconsumed(sql, U1);
expect(u).toHaveLength(1);
expect(u[0].title).toBe("web1");
});
it("consume sets consumed=true and is reflected in getUnconsumed", async () => {
const t = await repo.createWebTask(sql, U1, { listId: "L", title: "c", description: null });
expect(await repo.consume(sql, U1, t.id)).toBe(true);
expect(await repo.getUnconsumed(sql, U1)).toEqual([]);
});
it("consume on unknown id returns false", async () => {
expect(await repo.consume(sql, U1, "nope")).toBe(false);
});
it("deleteTask is idempotent", async () => {
const t = await repo.createWebTask(sql, U1, { listId: "L", title: "c", description: null });
await repo.deleteTask(sql, U1, t.id);
await repo.deleteTask(sql, U1, t.id); // no throw on absent
expect(await repo.getTasksForList(sql, U1, "L")).toEqual([]);
});
});
describe("mirrorDesktopTasks (desktop Idle full-replace)", () => {
beforeEach(async () => {
await repo.replaceLists(sql, U1, [
{ id: "L", name: "List" },
{ id: "L2", name: "List 2" },
]);
});
it("inserts items as desktop-owned (consumed=true, source=desktop, stamped owner)", async () => {
await repo.mirrorDesktopTasks(sql, U1, [
{ id: "m1", listId: "L", title: "one", description: "d1" },
{ id: "m2", listId: "L2", title: "two" },
]);
const t = await repo.getTasksForList(sql, U1, "L");
expect(t).toHaveLength(1);
expect(t[0]).toMatchObject({ id: "m1", title: "one", description: "d1", source: "desktop", consumed: true, owner_id: U1 });
expect(await repo.getTasksForList(sql, U1, "L2")).toHaveLength(1);
});
it("upserts existing items by id (title/listId/description)", async () => {
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L", title: "one" }]);
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L2", title: "renamed", description: "x" }]);
const onL = await repo.getTasksForList(sql, U1, "L");
const onL2 = await repo.getTasksForList(sql, U1, "L2");
expect(onL).toHaveLength(0);
expect(onL2).toHaveLength(1);
expect(onL2[0]).toMatchObject({ title: "renamed", description: "x" });
});
it("deletes desktop tasks whose id is not in the array", async () => {
await repo.mirrorDesktopTasks(sql, U1, [
{ id: "m1", listId: "L", title: "one" },
{ id: "m2", listId: "L", title: "two" },
]);
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L", title: "one" }]); // m2 dropped
const t = await repo.getTasksForList(sql, U1, "L");
expect(t.map((x) => x.id)).toEqual(["m1"]);
});
it("empty array deletes all my desktop tasks", async () => {
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L", title: "one" }]);
await repo.mirrorDesktopTasks(sql, U1, []);
expect(await repo.getTasksForList(sql, U1, "L")).toEqual([]);
});
it("does NOT touch web-created tasks awaiting pull (consumed=false)", async () => {
const web = await repo.createWebTask(sql, U1, { listId: "L", title: "web", description: null });
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L", title: "desk" }]);
const unconsumed = await repo.getUnconsumed(sql, U1);
expect(unconsumed.map((x) => x.id)).toEqual([web.id]);
expect((await repo.getTasksForList(sql, U1, "L")).map((x) => x.id).sort()).toEqual([web.id, "m1"].sort());
});
it("claims a web task when its id is mirrored (becomes consumed=true)", async () => {
const web = await repo.createWebTask(sql, U1, { listId: "L", title: "web", description: null });
await repo.mirrorDesktopTasks(sql, U1, [{ id: web.id, listId: "L", title: "web (imported)" }]);
expect(await repo.getUnconsumed(sql, U1)).toEqual([]); // no longer awaiting pull
const t = await repo.getTasksForList(sql, U1, "L");
expect(t[0]).toMatchObject({ id: web.id, title: "web (imported)", consumed: true });
});
});
describe("owner isolation", () => {
it("lists are invisible to other users; legacy (NULL owner) lists visible to everyone", async () => {
await repo.replaceLists(sql, U1, [{ id: "mine", name: "Mine" }]);
await sql`insert into lists (id, name) values ('legacy', 'Old')`; // pre-multi-user row
expect((await repo.getLists(sql, U1)).map((l) => l.id).sort()).toEqual(["legacy", "mine"]);
expect((await repo.getLists(sql, U2)).map((l) => l.id)).toEqual(["legacy"]);
});
it("replaceLists never deletes or renames another user's lists", async () => {
await repo.replaceLists(sql, U1, [{ id: "a", name: "A" }]);
await repo.replaceLists(sql, U2, [{ id: "b", name: "B" }]); // U2's full catalog: just b
expect((await repo.getLists(sql, U1)).map((l) => l.id)).toEqual(["a"]);
expect((await repo.getLists(sql, U2)).map((l) => l.id)).toEqual(["b"]);
// U2 upserting U1's id must not steal/rename it
await repo.replaceLists(sql, U2, [{ id: "a", name: "stolen" }, { id: "b", name: "B" }]);
const u1Lists = await repo.getLists(sql, U1);
expect(u1Lists).toEqual([{ id: "a", name: "A", owner_id: U1 }]);
});
it("replaceLists adopts legacy lists (stamps the caller as owner)", async () => {
await sql`insert into lists (id, name) values ('legacy', 'Old')`;
await repo.replaceLists(sql, U1, [{ id: "legacy", name: "Old" }]);
expect((await repo.getLists(sql, U2)).map((l) => l.id)).toEqual([]); // no longer legacy
expect((await repo.getLists(sql, U1))[0]).toMatchObject({ id: "legacy", owner_id: U1 });
});
it("tasks are invisible to other users; legacy tasks visible to everyone", async () => {
await repo.replaceLists(sql, U1, [{ id: "L", name: "List" }]);
await repo.createWebTask(sql, U1, { listId: "L", title: "mine", description: null });
await sql`insert into tasks (id, list_id, title, source) values ('legacy-t', 'L', 'old', 'web')`;
expect((await repo.getUnconsumed(sql, U2)).map((t) => t.id)).toEqual(["legacy-t"]);
expect((await repo.getTasksForList(sql, U2, "L")).map((t) => t.id)).toEqual(["legacy-t"]);
expect(await repo.getUnconsumed(sql, U1)).toHaveLength(2);
});
it("one user's mirror never deletes another user's desktop tasks", async () => {
await repo.replaceLists(sql, U1, [{ id: "L1", name: "U1 List" }]);
await repo.replaceLists(sql, U2, [{ id: "L2", name: "U2 List" }]);
await repo.mirrorDesktopTasks(sql, U1, [{ id: "m1", listId: "L1", title: "u1 task" }]);
await repo.mirrorDesktopTasks(sql, U2, [{ id: "m2", listId: "L2", title: "u2 task" }]);
// U2's full-replace (only m2) must not have wiped U1's m1:
expect((await repo.getTasksForList(sql, U1, "L1")).map((t) => t.id)).toEqual(["m1"]);
// and an empty mirror from U2 clears only U2's partition:
await repo.mirrorDesktopTasks(sql, U2, []);
expect((await repo.getTasksForList(sql, U1, "L1")).map((t) => t.id)).toEqual(["m1"]);
expect(await repo.getTasksForList(sql, U2, "L2")).toEqual([]);
});
it("mirror upsert cannot claim another user's task by id", async () => {
await repo.replaceLists(sql, U1, [{ id: "L1", name: "U1 List" }]);
await repo.replaceLists(sql, U2, [{ id: "L2", name: "U2 List" }]);
const web = await repo.createWebTask(sql, U1, { listId: "L1", title: "u1 web", description: null });
await repo.mirrorDesktopTasks(sql, U2, [{ id: web.id, listId: "L2", title: "hijack" }]);
// U1's web task is untouched and still awaiting pull:
const u1 = await repo.getUnconsumed(sql, U1);
expect(u1.map((t) => t.id)).toEqual([web.id]);
expect(u1[0].title).toBe("u1 web");
});
it("upsertDesktopTask cannot overwrite another user's task", async () => {
await repo.replaceLists(sql, U1, [{ id: "L", name: "List" }]);
await repo.upsertDesktopTask(sql, U1, "shared-id", { listId: "L", title: "u1 owns this" });
const res = await repo.upsertDesktopTask(sql, U2, "shared-id", { listId: "L", title: "u2 steal" });
expect(res.created).toBe(false);
expect((await repo.getTasksForList(sql, U1, "L"))[0].title).toBe("u1 owns this");
});
it("consume and delete are scoped to the caller", async () => {
await repo.replaceLists(sql, U1, [{ id: "L", name: "List" }]);
const t = await repo.createWebTask(sql, U1, { listId: "L", title: "w", description: null });
expect(await repo.consume(sql, U2, t.id)).toBe(false);
await repo.deleteTask(sql, U2, t.id); // no-op for U2
expect((await repo.getUnconsumed(sql, U1)).map((x) => x.id)).toEqual([t.id]);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
source ~/.secrets/coolify-tokens.env
export DATABASE_URL="postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/claudedo_test"
bun run test tests/repo.test.ts
```
Expected: FAIL — the repo functions don't accept the `ownerId` argument yet (calls pass owner where lists/task args are expected, assertions on `owner_id` fail).
- [ ] **Step 3: Rewrite `server/utils/repo.ts` with owner scoping**
Full file content:
```ts
import postgres from "postgres";
import { randomUUID } from "node:crypto";
type Sql = ReturnType<typeof postgres>;
export interface ListRow {
id: string;
name: string;
owner_id: string | null;
}
export interface TaskRow {
id: string;
list_id: string;
title: string;
description: string | null;
source: string;
consumed: boolean;
owner_id: string | null;
created_at: Date;
updated_at: Date;
}
// Ownership model: every read/write is scoped to the caller's Zitadel sub (ownerId).
// owner_id NULL = legacy row from before multi-user — visible to every authorized user
// and adopted (stamped with the caller's sub) by the caller's next write that touches it.
// Upserts use `on conflict … where` so they can never modify a row owned by someone else.
export async function getLists(sql: Sql, ownerId: string): Promise<ListRow[]> {
return sql<ListRow[]>`
select id, name, owner_id from lists
where owner_id = ${ownerId} or owner_id is null
order by name`;
}
/** Full-replace of the caller's catalog: upsert all supplied, delete the caller's (and legacy) lists not present (cascades tasks). */
export async function replaceLists(
sql: Sql,
ownerId: string,
lists: { id: string; name: string }[],
): Promise<void> {
await sql.begin(async (tx) => {
for (const l of lists) {
await tx`
insert into lists (id, name, owner_id, updated_at)
values (${l.id}, ${l.name}, ${ownerId}, now())
on conflict (id) do update set name = excluded.name, owner_id = ${ownerId}, updated_at = now()
where lists.owner_id = ${ownerId} or lists.owner_id is null`;
}
const ids = lists.map((l) => l.id);
if (ids.length) {
await tx`delete from lists where (owner_id = ${ownerId} or owner_id is null) and id <> all(${ids})`;
} else {
await tx`delete from lists where owner_id = ${ownerId} or owner_id is null`;
}
});
}
export async function listExists(sql: Sql, ownerId: string, id: string): Promise<boolean> {
const rows = await sql`
select 1 from lists where id = ${id} and (owner_id = ${ownerId} or owner_id is null)`;
return rows.length > 0;
}
export async function getTasksForList(sql: Sql, ownerId: string, listId: string): Promise<TaskRow[]> {
return sql<TaskRow[]>`
select * from tasks
where list_id = ${listId} and (owner_id = ${ownerId} or owner_id is null)
order by created_at`;
}
export async function createWebTask(
sql: Sql,
ownerId: string,
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, owner_id)
values (${id}, ${t.listId}, ${t.title}, ${t.description}, 'web', false, ${ownerId})
returning *`;
return row;
}
/** Idempotent upsert of a desktop Idle task by id. Returns whether a row was inserted. */
export async function upsertDesktopTask(
sql: Sql,
ownerId: string,
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, owner_id)
values (${id}, ${t.listId}, ${t.title}, ${t.description ?? null}, 'desktop', ${ownerId})
on conflict (id) do update set
list_id = excluded.list_id,
title = excluded.title,
description = excluded.description,
owner_id = ${ownerId},
updated_at = now()
where tasks.owner_id = ${ownerId} or tasks.owner_id is null
returning (xmax = 0) as created`;
// row is undefined when the id exists but belongs to another user: blocked no-op.
return { created: row?.created ?? false };
}
/**
* Full-replace of the caller's desktop-owned partition (their current Idle backlog).
* Mirrors `replaceLists`: upsert every supplied task as desktop-owned (consumed=true), and
* delete any of the caller's (or legacy) desktop-owned tasks not in the payload. Web-created
* tasks still awaiting pull (consumed=false) and other users' rows are left untouched.
*/
export async function mirrorDesktopTasks(
sql: Sql,
ownerId: string,
items: { id: string; listId: string; title: string; description?: string | null }[],
): Promise<void> {
await sql.begin(async (tx) => {
for (const t of items) {
await tx`
insert into tasks (id, list_id, title, description, source, consumed, owner_id)
values (${t.id}, ${t.listId}, ${t.title}, ${t.description ?? null}, 'desktop', true, ${ownerId})
on conflict (id) do update set
list_id = excluded.list_id,
title = excluded.title,
description = excluded.description,
source = 'desktop',
consumed = true,
owner_id = ${ownerId},
updated_at = now()
where tasks.owner_id = ${ownerId} or tasks.owner_id is null`;
}
const ids = items.map((t) => t.id);
if (ids.length) {
await tx`delete from tasks
where consumed = true and (owner_id = ${ownerId} or owner_id is null) and id <> all(${ids})`;
} else {
await tx`delete from tasks where consumed = true and (owner_id = ${ownerId} or owner_id is null)`;
}
});
}
/** The caller's web-created tasks the desktop has not yet imported. */
export async function getUnconsumed(sql: Sql, ownerId: string): Promise<TaskRow[]> {
return sql<TaskRow[]>`
select * from tasks
where source = 'web' and consumed = false and (owner_id = ${ownerId} or owner_id is null)
order by created_at`;
}
export async function consume(sql: Sql, ownerId: string, id: string): Promise<boolean> {
const res = await sql`
update tasks set consumed = true, updated_at = now()
where id = ${id} and (owner_id = ${ownerId} or owner_id is null)`;
return res.count > 0;
}
export async function deleteTask(sql: Sql, ownerId: string, id: string): Promise<void> {
await sql`delete from tasks where id = ${id} and (owner_id = ${ownerId} or owner_id is null)`;
}
```
- [ ] **Step 4: Run the repo tests to verify they pass**
```bash
bun run test tests/repo.test.ts
```
Expected: PASS (all describe blocks, including `owner isolation`).
Note: `bun run build` / the handlers will be broken at this point (signature change) — that is expected and fixed in Tasks 34. Do not run the build here.
- [ ] **Step 5: Commit**
```bash
git add server/utils/repo.ts tests/repo.test.ts
git commit -m "feat: scope all repo reads/writes to the caller's ownerId"
```
---
### Task 3: `ownerOf` helper + DTO `ownerId`
**Files:**
- Create: `server/utils/session.ts`
- Modify: `server/utils/dto.ts`
- [ ] **Step 1: Create `server/utils/session.ts`**
Nitro auto-imports everything in `server/utils/`, so handlers can call `ownerOf(event)` without importing. The token is verified by `server/middleware/1.auth.ts` before any handler runs; this helper just extracts the sub (and hard-fails if a verified token somehow lacks one). Client-supplied `ownerId` in request bodies is never read — this is the only ownership source.
```ts
import { createError, type H3Event } from "h3";
/** The authenticated caller's Zitadel sub — the ownership key for all row scoping. */
export function ownerOf(event: H3Event): string {
const sub = (event.context.user as { sub?: unknown } | undefined)?.sub;
if (typeof sub !== "string" || !sub) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
return sub;
}
```
- [ ] **Step 2: Add `ownerId` to the task DTO**
Replace the full content of `server/utils/dto.ts`:
```ts
import type { TaskRow } from "./repo";
export interface TaskDto {
id: string;
listId: string;
title: string;
description: string | null;
source: string;
consumed: boolean;
ownerId: string | null;
createdAt: string;
}
export function toTaskDto(row: TaskRow): TaskDto {
return {
id: row.id,
listId: row.list_id,
title: row.title,
description: row.description,
source: row.source,
consumed: row.consumed,
ownerId: row.owner_id,
createdAt: new Date(row.created_at).toISOString(),
};
}
```
- [ ] **Step 3: Commit**
```bash
git add server/utils/session.ts server/utils/dto.ts
git commit -m "feat: ownerOf(event) helper and ownerId in task DTO"
```
(No test run here — handlers still reference old repo signatures until Task 4; the unit suites in `tests/` don't touch these two files' behavior in isolation.)
---
### Task 4: Thread `ownerId` through every API handler
**Files:**
- Modify: `server/api/lists.get.ts`
- Modify: `server/api/lists.put.ts`
- Modify: `server/api/lists/[id]/tasks.get.ts`
- Modify: `server/api/tasks.post.ts`
- Modify: `server/api/tasks.get.ts`
- Modify: `server/api/tasks/mirror.put.ts`
- Modify: `server/api/tasks/[id].put.ts`
- Modify: `server/api/tasks/[id].delete.ts`
- Modify: `server/api/tasks/[id]/consume.post.ts`
All nine handlers below are complete file contents — replace each file wholesale. Behavior notes: any `ownerId` a client sends in a body is ignored (we never read it); the server stamps from the token.
- [ ] **Step 1: `server/api/lists.get.ts`**
```ts
// GET /api/lists (web) — the caller's lists (plus legacy unowned).
export default defineEventHandler(async (event) => {
const rows = await getLists(getSql(), ownerOf(event));
return rows.map((r) => ({ id: r.id, name: r.name, ownerId: r.owner_id }));
});
```
- [ ] **Step 2: `server/api/lists.put.ts`**
```ts
// PUT /api/lists (desktop) — full-replace of the caller's catalog. Upsert all supplied;
// delete the caller's lists not present. Any client-supplied ownerId is ignored — the
// server stamps ownership from the verified token.
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(),
ownerOf(event),
body.map((l) => ({ id: l.id, name: l.name })),
);
return { ok: true };
});
```
- [ ] **Step 3: `server/api/lists/[id]/tasks.get.ts`**
```ts
// GET /api/lists/:id/tasks (web) — the caller's Idle tasks for a list. 404 if the list is unknown (to the caller).
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id")!;
const ownerId = ownerOf(event);
const sql = getSql();
if (!(await listExists(sql, ownerId, id))) {
throw createError({ statusCode: 404, statusMessage: "list not found" });
}
const rows = await getTasksForList(sql, ownerId, id);
return rows.map(toTaskDto);
});
```
- [ ] **Step 4: `server/api/tasks.post.ts`**
```ts
// POST /api/tasks (web) — create an Idle task with a server-generated GUID, owned by the caller.
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const title = typeof body?.title === "string" ? body.title.trim() : "";
const listId = body?.listId;
if (!title || typeof listId !== "string") {
throw createError({ statusCode: 400, statusMessage: "title and listId are required" });
}
const description =
typeof body?.description === "string" && body.description.trim() ? body.description : null;
const ownerId = ownerOf(event);
const sql = getSql();
if (!(await listExists(sql, ownerId, listId))) {
throw createError({ statusCode: 404, statusMessage: "list not found" });
}
const row = await createWebTask(sql, ownerId, { listId, title, description });
setResponseStatus(event, 201);
return toTaskDto(row);
});
```
- [ ] **Step 5: `server/api/tasks.get.ts`**
```ts
// GET /api/tasks?consumed=false (desktop) — the caller's web-created tasks not yet imported.
export default defineEventHandler(async (event) => {
const rows = await getUnconsumed(getSql(), ownerOf(event));
return rows.map((r) => ({
id: r.id,
listId: r.list_id,
title: r.title,
description: r.description,
ownerId: r.owner_id,
createdAt: new Date(r.created_at).toISOString(),
}));
});
```
- [ ] **Step 6: `server/api/tasks/mirror.put.ts`**
```ts
// PUT /api/tasks/mirror (desktop) — full-replace of the caller's desktop Idle backlog.
// Body: [{ id, listId, title, description? }, ...] (camelCase). An empty array is valid and
// clears the caller's desktop-owned partition. Mirrors PUT /lists. Web-created tasks awaiting
// pull (consumed=false) and other users' rows are never touched. Any client-supplied ownerId
// on items is ignored — ownership comes from the verified token.
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!Array.isArray(body)) {
throw createError({ statusCode: 400, statusMessage: "expected an array of tasks" });
}
const items: { id: string; listId: string; title: string; description: string | null }[] = [];
for (let i = 0; i < body.length; i++) {
const t = body[i];
if (typeof t?.id !== "string" || !t.id.trim()) {
throw createError({ statusCode: 400, statusMessage: `item ${i}: id is required` });
}
if (typeof t?.listId !== "string" || !t.listId.trim()) {
throw createError({ statusCode: 400, statusMessage: `item ${i}: listId is required` });
}
if (typeof t?.title !== "string" || !t.title.trim()) {
throw createError({ statusCode: 400, statusMessage: `item ${i}: title is required` });
}
items.push({
id: t.id,
listId: t.listId,
title: t.title,
description: typeof t.description === "string" ? t.description : null,
});
}
const ownerId = ownerOf(event);
const sql = getSql();
// Every referenced list must exist for the caller (lists are full-replaced before tasks are mirrored).
const listIds = [...new Set(items.map((t) => t.listId))];
for (const id of listIds) {
if (!(await listExists(sql, ownerId, id))) {
throw createError({ statusCode: 400, statusMessage: `unknown listId: ${id}` });
}
}
await mirrorDesktopTasks(sql, ownerId, items);
return { ok: true, count: items.length };
});
```
- [ ] **Step 7: `server/api/tasks/[id].put.ts`**
```ts
// PUT /api/tasks/:id (desktop) — idempotent upsert mirroring a desktop Idle task, owned by the caller.
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id")!;
const body = await readBody(event);
const title = body?.title;
const listId = body?.listId;
if (typeof title !== "string" || !title.trim() || typeof listId !== "string") {
throw createError({ statusCode: 400, statusMessage: "listId and title are required" });
}
const description = typeof body?.description === "string" ? body.description : null;
const ownerId = ownerOf(event);
const sql = getSql();
if (!(await listExists(sql, ownerId, listId))) {
throw createError({ statusCode: 404, statusMessage: "list not found" });
}
const { created } = await upsertDesktopTask(sql, ownerId, id, { listId, title, description });
setResponseStatus(event, created ? 201 : 200);
return { id };
});
```
- [ ] **Step 8: `server/api/tasks/[id].delete.ts`**
```ts
// DELETE /api/tasks/:id (desktop) — task left Idle on desktop. Idempotent, scoped to the caller's rows.
export default defineEventHandler(async (event) => {
await deleteTask(getSql(), ownerOf(event), getRouterParam(event, "id")!);
setResponseStatus(event, 204);
return null;
});
```
- [ ] **Step 9: `server/api/tasks/[id]/consume.post.ts`**
```ts
// POST /api/tasks/:id/consume (desktop) — mark the caller's web task imported. Idempotent.
export default defineEventHandler(async (event) => {
const ok = await consume(getSql(), ownerOf(event), getRouterParam(event, "id")!);
if (!ok) {
throw createError({ statusCode: 404, statusMessage: "task not found" });
}
return { ok: true };
});
```
- [ ] **Step 10: Full test suite + build**
```bash
source ~/.secrets/coolify-tokens.env
export DATABASE_URL="postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/claudedo_test"
bun run test
bun run build
```
Expected: all tests PASS; `nuxt build` completes without errors (this is the type/wiring check for the handlers — they have no unit tests of their own; the repo layer they delegate to is covered).
- [ ] **Step 11: Commit**
```bash
git add server/api server/utils
git commit -m "feat: scope every API endpoint to the token's sub; expose ownerId in DTOs"
```
---
### Task 5: FE — owner filtering (defense-in-depth) + missing-role state
**Files:**
- Modify: `app/composables/useAuth.ts`
- Modify: `app/pages/index.vue` (script + small template additions; styles untouched)
- [ ] **Step 1: Surface the HTTP status on API errors**
Replace the full content of `app/composables/useAuth.ts`:
```ts
import type { ZitadelAuth } from "@kuns/zitadel-auth";
/** API call failure carrying the HTTP status (401 = authenticated but not authorized). */
export class ApiError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
// Access the bootstrap-provided Zitadel auth instance + a small JSON helper for /api calls.
// By the time any component mounts, the plugin has gated auth, so `auth` is authenticated.
// `auth.fetch` auto-attaches the Bearer access token.
export function useAuth() {
const { $auth } = useNuxtApp() as unknown as { $auth: ZitadelAuth };
async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await $auth.fetch(`/api${path}`, init);
if (!res.ok) {
let message = `${res.status}`;
try {
const body = await res.json();
message = body?.statusMessage || body?.message || message;
} catch {
// non-JSON error body
}
throw new ApiError(message, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
return { auth: $auth, api };
}
```
- [ ] **Step 2: index.vue script — ownerId types, owner filter, 401→missing-role handling**
In `app/pages/index.vue` make these script edits (each shown as exact old → new):
(a) Add an explicit import as the first line inside `<script setup lang="ts">` (the `useAuth` composable itself stays auto-imported; the class import is explicit to be unambiguous):
```ts
import { ApiError } from "~/composables/useAuth";
```
(b) Extend both interfaces — replace:
```ts
interface List {
id: string;
name: string;
}
interface Task {
id: string;
listId: string;
title: string;
description: string | null;
source: string;
consumed: boolean;
createdAt: string;
}
```
with:
```ts
interface List {
id: string;
name: string;
ownerId?: string | null;
}
interface Task {
id: string;
listId: string;
title: string;
description: string | null;
source: string;
consumed: boolean;
ownerId?: string | null;
createdAt: string;
}
```
(c) After the line `const error = ref<string | null>(null);` add:
```ts
// Authenticated but the token lacks the required Zitadel project role → API answers 401.
const missingRole = ref(false);
// Defense-in-depth: the server already scopes every query by the token's sub; additionally
// hide anything not owned by the current user. Absent ownerId = legacy (pre-multi-user) row.
const mine = (x: { ownerId?: string | null }) => !x.ownerId || x.ownerId === auth.user?.sub;
function handleApiError(e: unknown) {
if (e instanceof ApiError && e.status === 401) {
missingRole.value = true;
} else {
error.value = (e as Error).message;
}
}
```
(d) In `refreshLists`, replace:
```ts
lists.value = await api<List[]>("/lists");
```
with:
```ts
lists.value = (await api<List[]>("/lists")).filter(mine);
```
and replace its catch body `error.value = (e as Error).message;` with `handleApiError(e);`
(e) In `refreshTasks`, replace:
```ts
tasks.value = await api<Task[]>(`/lists/${selectedId.value}/tasks`);
```
with:
```ts
tasks.value = (await api<Task[]>(`/lists/${selectedId.value}/tasks`)).filter(mine);
```
and replace its catch body `error.value = (e as Error).message;` with `handleApiError(e);`
(f) In `addTask`, replace the catch body `error.value = (e as Error).message;` with `handleApiError(e);`
- [ ] **Step 3: index.vue template — missing-role state**
(a) In `<main class="content">`, add a new first branch ahead of the existing `<template v-if="loadingLists">` (and change that one to `v-else-if`):
```html
<template v-if="missingRole">
<div class="empty">
<p class="empty-mark"></p>
<p class="empty-title">No access yet</p>
<p class="muted">
You're signed in, but your account is missing the “user” role for ClaudeDo.
Ask the admin to grant it in Zitadel, then sign in again.
</p>
<button class="link" @click="auth.logout()">Sign out</button>
</div>
</template>
<template v-else-if="loadingLists">
```
(The old `<template v-if="loadingLists">` line is the only line that changes to `v-else-if`; the rest of the chain below it already uses `v-else-if`/`v-else` and stays as is. Keep the existing `<p v-if="error" …>` line above this block unchanged.)
(b) Hide the composer dock in that state — replace:
```html
<footer v-if="lists.length" class="dock">
```
with:
```html
<footer v-if="lists.length && !missingRole" class="dock">
```
- [ ] **Step 4: Build to verify the FE compiles**
```bash
bun run build
```
Expected: build completes without errors.
- [ ] **Step 5 (optional, if quick visual confirmation is wanted):** use the local preview approach from memory `local-ui-preview.md` (seed fake oidc user in localStorage + Playwright API mocks returning 401) to screenshot the missing-role state. Skip if the build is green and time is short — the state is simple.
- [ ] **Step 6: Commit**
```bash
git add app/composables/useAuth.ts app/pages/index.vue
git commit -m "feat(web): filter by ownerId and surface missing-role 401 state"
```
---
### Task 6: Docs + deploy
**Files:**
- Modify: `README.md` (API section, lines ~2948)
- [ ] **Step 1: Document the ownership model in README.md**
In the `## API` intro, replace:
```markdown
Every `/api/**` route requires a valid Zitadel **access token** (`Authorization: Bearer …`)
belonging to the owner. Missing/invalid/expired → `401`. No anonymous access.
```
with:
```markdown
Every `/api/**` route requires a valid Zitadel **access token** (`Authorization: Bearer …`)
carrying the `user` project role. Missing/invalid/expired/role-less → `401`. No anonymous access.
**Ownership:** every row carries `owner_id` = the writer's token `sub`. All reads and writes are
scoped server-side to the caller (`owner_id = sub OR owner_id IS NULL`); full-replace endpoints
only replace the caller's partition. `owner_id IS NULL` marks legacy pre-multi-user rows — visible
to all authorized users and adopted by the next write that touches them. DTOs expose this as a
nullable `ownerId`; any client-supplied `ownerId` is ignored.
```
And in the endpoint table, replace the `GET /api/lists` row:
```markdown
| `GET /api/lists` | web | → 200 `[{id,name}]` |
```
with:
```markdown
| `GET /api/lists` | web | → 200 `[{id,name,ownerId}]` |
```
and the `GET /api/tasks?consumed=false` row:
```markdown
| `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, createdAt}]` web tasks not yet imported (awaiting pull) |
```
with:
```markdown
| `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, ownerId, createdAt}]` web tasks not yet imported (awaiting pull) |
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: ownership model and ownerId in API contract"
```
- [ ] **Step 3: Deploy**
Production schema migrates itself on boot (`server/plugins/migrate.ts` applies the new `alter table` lines idempotently). No new env vars are needed.
```bash
redeploy xry0o2jdaz5125qrx5t32xhx "feat: per-user data isolation (ownerId scoping)"
```
Then poll until the deployment finishes:
```bash
coolify-status xry0o2jdaz5125qrx5t32xhx
```
Expected: status `running:healthy` on the new deployment.
- [ ] **Step 4: Verify production schema + service**
```bash
source ~/.secrets/coolify-tokens.env
psql "postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/claudedo" \
-c "select column_name from information_schema.columns where table_name in ('lists','tasks') and column_name='owner_id'"
curl -s -o /dev/null -w "%{http_code}" https://claudedo.kuns.dev/api/lists
```
Expected: two `owner_id` rows; `401` from the unauthenticated curl (auth gate alive).
- [ ] **Step 5: Update the project memory file**
Append to `/home/mika/.claude/projects/-home-mika-claudedo-online/memory/claudedo-online.md` (under the existing bullets):
```markdown
- **Per-user isolation (since 2026-06-11):** `lists.owner_id` / `tasks.owner_id` (nullable text = Zitadel sub; NULL = legacy, visible to all, adopted on next write). Every repo fn takes `ownerId` (from `ownerOf(event)` in `server/utils/session.ts`); reads filter `owner_id = sub OR NULL`, full-replace deletes are scoped to the caller's partition, upserts use `ON CONFLICT … WHERE` so they can't touch other users' rows. DTOs expose nullable `ownerId`; client-supplied ownerId ignored. FE filters by `auth.user.sub` and shows a missing-role screen on 401.
```
---
## Out of scope (deliberately)
- **Backfilling `owner_id` for existing prod rows:** unnecessary — the desktop's next sync (`PUT /lists` + `PUT /tasks/mirror`) full-replaces and thereby adopts/stamps everything; web-created legacy tasks stay visible (NULL = legacy) until consumed.
- **Indexes on `owner_id`:** table sizes are tiny (single-digit users); add later if ever needed.
- **Desktop repo changes / `docs/online-inbox-api-contract.md`:** lives in the ClaudeDo desktop repo, already updated by the desktop session per the handoff.
- **Changing the role gate:** already shipped in `d4c7347`.