Eighteen tasks, 86 steps, covering the PocketBase schema, auth, the field-level whitelist that PocketBase rules cannot express, the three client routes, and all five acceptance tests from the spec. Board UI is deferred to Plan 1b so the security boundary can be reviewed on its own rather than inside a large UI diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 KiB
Phase 1a — Data Layer and Permissions 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: Build the PocketBase schema, authentication, and the field-level permission layer for the client board, with every one of the five acceptance tests in the spec passing.
Architecture: A Next.js App Router application backed by PocketBase. PocketBase API rules allow the owner to write directly and deny client managers all writes. Client-manager writes instead go through Next.js route handlers that authenticate as a PocketBase superuser and whitelist fields explicitly, because PocketBase rules are record-level and cannot express field-level permission. Pure logic (ordering, field whitelisting, activity diffing, access checks) lives in dependency-free modules that are unit-tested without a database; the route handlers are covered by integration tests against a live PocketBase.
Tech Stack: Next.js 16 (App Router) · React 19 · TypeScript · PocketBase 0.30 (server) + SDK 0.26 · Tailwind 4 · vitest · tsx
Spec: docs/superpowers/specs/2026-09-16-client-board-phase-1-design.md
Scope note: This plan covers the spec's §4, §5, §6, §7, and §9. The spec's §8 (pages) and the drag-and-drop board are deliberately deferred to Plan 1b, written after this plan is implemented and reviewed. The only UI in this plan is a login form, because auth cannot be exercised without one.
Domain primer
Read this before Task 1. It is the context a developer new to this codebase needs.
The product. A solo developer builds software for several client companies. This board tracks that work. The developer is the sole owner. Each client company gets one or more client_manager accounts. A client manager sees only their own company's projects.
The one rule that shapes everything. A client manager may edit exactly two fields on a card — priority and target_date — and may create cards only into their project's Inbox column. They may not move cards, rename them, or change their status. PocketBase cannot express this. Its API rules operate on whole records, not fields. So:
- PocketBase rules for cards allow writes only when
@request.auth.role = "owner". A client manager's token is refused outright. - Client-manager writes go through Next.js route handlers that authenticate as a PocketBase superuser (which bypasses rules) and copy across only whitelisted fields.
If you instead let the client browser write to PocketBase directly and hide the buttons in the UI, the board will look correct and a client will be able to change any field with one curl command. This is the highest-risk mistake available in this plan.
Reads are different. Client managers read directly from PocketBase using normal scoped rules. Reads are record-level, so rules handle them fine, and this keeps realtime subscriptions working.
PocketBase specifics you will need.
- Version 0.30. Migrations are JavaScript files in
pocketbase/pb_migrations/, applied automatically on server start, named<unix_timestamp>_<description>.js. - The migration API is
migrate((app) => { ...up... }, (app) => { ...down... }). Collections are created withnew Collection({...})and saved withapp.save(collection). - Relation fields need the target collection's id, not its name. Always resolve it at migration time with
app.findCollectionByNameOrId("clients").idrather than hardcoding. - Superusers live in the
_superuserscollection:pb.collection("_superusers").authWithPassword(email, password). There is no separate admin API in 0.30. - A rule of
nullmeans superuser only. A rule of""(empty string) means anyone, including unauthenticated. These are very different; do not confuse them.
Conventions. Commit messages are conventional commits with a scope, matching the developer's other repositories: feat(auth): ..., fix(cards): ..., test(ordering): ....
File structure
Files that change together live together. Each module below has one responsibility and is testable in isolation.
KANBANBOARD-DEV/
├── package.json
├── tsconfig.json
├── next.config.ts
├── postcss.config.mjs
├── vitest.config.ts
├── .env.example
├── .gitignore
├── pocketbase/
│ ├── pocketbase # binary, gitignored
│ └── pb_migrations/
│ ├── 1789600100_created_clients.js
│ ├── 1789600200_created_projects.js
│ ├── 1789600300_extended_users.js
│ ├── 1789600400_created_columns.js
│ ├── 1789600500_created_cards.js
│ ├── 1789600600_created_subtasks_comments.js
│ ├── 1789600700_created_activity.js
│ ├── 1789600800_created_settings.js
│ └── 1789600900_applied_api_rules.js
├── scripts/
│ └── seed-demo.ts # demo client, project, manager, cards
├── src/
│ ├── lib/
│ │ ├── ordering/
│ │ │ ├── fractional.ts # position arithmetic, rebalance
│ │ │ └── fractional.test.ts
│ │ ├── cards/
│ │ │ ├── client-patch.ts # field whitelist for client writes
│ │ │ └── client-patch.test.ts
│ │ ├── activity/
│ │ │ ├── diff.ts # changed fields -> activity rows
│ │ │ └── diff.test.ts
│ │ ├── auth/
│ │ │ ├── access.ts # pure access predicates
│ │ │ ├── access.test.ts
│ │ │ ├── cookie.ts # cookie name only — edge-safe
│ │ │ └── session.ts # cookie <-> PocketBase auth store
│ │ ├── columns/
│ │ │ ├── seed.ts # default column set for a new project
│ │ │ └── seed.test.ts
│ │ ├── pb/
│ │ │ ├── browser.ts # client-side PB instance
│ │ │ ├── server.ts # request-scoped PB, user's token
│ │ │ ├── admin.ts # superuser PB, server-only
│ │ │ └── types.ts # record type definitions
│ │ └── http/
│ │ └── json.ts # consistent JSON error responses
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── globals.css
│ │ ├── page.tsx # placeholder until Plan 1b
│ │ ├── login/page.tsx
│ │ └── api/
│ │ ├── auth/
│ │ │ ├── login/route.ts
│ │ │ └── logout/route.ts
│ │ └── client/
│ │ └── cards/
│ │ ├── route.ts # POST — create into Inbox
│ │ └── [id]/
│ │ ├── route.ts # PATCH — priority/target_date
│ │ └── comments/route.ts # POST — comment
│ └── middleware.ts
└── tests/
└── integration/
├── helpers.ts
├── client-permissions.test.ts # spec §9 tests 1, 2, 3
└── activity-log.test.ts # spec §9 test 5
Spec §9 test 4 (fractional ordering) is a unit test and lives in src/lib/ordering/fractional.test.ts.
Task 1: Project scaffold
Files:
-
Create:
package.json,tsconfig.json,next.config.ts,postcss.config.mjs,vitest.config.ts,.gitignore,.env.example -
Create:
src/app/layout.tsx,src/app/globals.css,src/app/page.tsx -
Test:
src/lib/ordering/fractional.test.ts(created in Task 2; this task only proves vitest runs) -
Step 1: Create
package.json
{
"name": "client-board",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"pb": "cd pocketbase && ./pocketbase serve --http=127.0.0.1:8090",
"test": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:all": "vitest run",
"seed": "tsx scripts/seed-demo.ts",
"lint": "next lint"
},
"dependencies": {
"next": "16.2.6",
"pocketbase": "^0.26.9",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^24.5.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"tailwindcss": "^4.1.13",
"tsx": "^4.20.6",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
- Step 2: Create
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "pocketbase"]
}
- Step 3: Create config files
next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
postcss.config.mjs:
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;
vitest.config.ts — two projects, because unit tests must run with no PocketBase present while integration tests require one:
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
projects: [
{
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
test: {
name: "unit",
environment: "node",
include: ["src/**/*.test.ts"],
},
},
{
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
test: {
name: "integration",
environment: "node",
include: ["tests/integration/**/*.test.ts"],
hookTimeout: 30_000,
testTimeout: 30_000,
fileParallelism: false,
},
},
],
},
});
fileParallelism: false is what stops integration test files racing each other against one shared database.
- Step 4: Create
.gitignoreand.env.example
.gitignore:
node_modules/
.next/
.env.local
next-env.d.ts
*.tsbuildinfo
pocketbase/pocketbase
pocketbase/pb_data/
.env.example:
NEXT_PUBLIC_PB_URL=http://127.0.0.1:8090
PB_SUPERUSER_EMAIL=admin@example.com
PB_SUPERUSER_PASSWORD=change-me-please
- Step 5: Create the minimal app shell
src/app/globals.css:
@import "tailwindcss";
:root {
color-scheme: dark;
}
body {
@apply bg-neutral-950 text-neutral-100 antialiased;
}
src/app/layout.tsx:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Client Board",
description: "Project tracking for client work",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
src/app/page.tsx:
export default function Home() {
return (
<main className="p-8">
<h1 className="text-2xl font-semibold">Client Board</h1>
<p className="mt-2 text-neutral-400">
The board UI arrives in Plan 1b. Sign in at <code>/login</code>.
</p>
</main>
);
}
- Step 6: Install and verify the build
npm install
npm run build
Expected: build completes, printing a route list containing / and no errors.
- Step 7: Commit
git add -A
git commit -m "chore(scaffold): Next.js 16 + Tailwind 4 + vitest project skeleton"
Task 2: Fractional ordering module
Implements spec §7. This is pure arithmetic with no dependencies, so it is built test-first.
Why fractional indices: dragging a card into position must write one row, not renumber a whole column. Storing position as a float and inserting at the midpoint of its neighbours achieves that. The cost is that repeatedly inserting into the same slot halves the gap each time, and after roughly 50 insertions the gap reaches the limit of float precision and two cards silently collide. needsRebalance detects that approach and rebalance renumbers the column.
Files:
-
Create:
src/lib/ordering/fractional.ts -
Test:
src/lib/ordering/fractional.test.ts -
Step 1: Write the failing tests
src/lib/ordering/fractional.test.ts:
import { describe, it, expect } from "vitest";
import {
SPACING,
MIN_GAP,
positionBetween,
needsRebalance,
rebalance,
} from "@/lib/ordering/fractional";
describe("positionBetween", () => {
it("returns SPACING for the first card in an empty column", () => {
expect(positionBetween(null, null)).toBe(SPACING);
});
it("appends one SPACING beyond the last card", () => {
expect(positionBetween(3000, null)).toBe(3000 + SPACING);
});
it("prepends one SPACING before the first card", () => {
expect(positionBetween(null, 1000)).toBe(1000 - SPACING);
});
it("returns the midpoint between two neighbours", () => {
expect(positionBetween(1000, 2000)).toBe(1500);
});
it("throws when the neighbours are out of order", () => {
expect(() => positionBetween(2000, 1000)).toThrow(/out of order/i);
});
});
describe("needsRebalance", () => {
it("is false for a well-spaced column", () => {
expect(needsRebalance([1000, 2000, 3000])).toBe(false);
});
it("is false for zero or one card", () => {
expect(needsRebalance([])).toBe(false);
expect(needsRebalance([1000])).toBe(false);
});
it("is true once an adjacent gap falls below MIN_GAP", () => {
expect(needsRebalance([1000, 1000 + MIN_GAP / 2, 2000])).toBe(true);
});
it("becomes true after repeated midpoint insertion into one slot", () => {
let lo = 1000;
const hi = 2000;
for (let i = 0; i < 60; i++) {
lo = positionBetween(lo, hi);
}
expect(needsRebalance([1000, lo, hi])).toBe(true);
});
});
describe("rebalance", () => {
it("spaces ids evenly starting at SPACING and preserves order", () => {
expect(rebalance(["a", "b", "c"])).toEqual([
{ id: "a", position: 1000 },
{ id: "b", position: 2000 },
{ id: "c", position: 3000 },
]);
});
it("returns an empty list for an empty column", () => {
expect(rebalance([])).toEqual([]);
});
it("produces positions that no longer need rebalancing", () => {
let lo = 1000;
const hi = 2000;
for (let i = 0; i < 60; i++) lo = positionBetween(lo, hi);
const ordered = [
{ id: "a", position: 1000 },
{ id: "b", position: lo },
{ id: "c", position: hi },
].sort((x, y) => x.position - y.position);
const result = rebalance(ordered.map((r) => r.id));
expect(needsRebalance(result.map((r) => r.position))).toBe(false);
expect(result.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/ordering/fractional".
- Step 3: Write the implementation
src/lib/ordering/fractional.ts:
/**
* Fractional index arithmetic for card ordering.
*
* A card's `position` is a float. Inserting between two neighbours takes
* their midpoint, so a drag writes a single row instead of renumbering the
* whole column. The tradeoff is finite precision: each midpoint insertion
* into the same slot halves the gap, and around the 50th the gap reaches
* the limit of a float64 and two cards silently collide. `needsRebalance`
* detects that approach; `rebalance` renumbers the column before it happens.
*/
/** Distance between consecutive cards in a freshly numbered column. */
export const SPACING = 1000;
/**
* Smallest adjacent gap tolerated before a column is renumbered. Well above
* float64 epsilon at these magnitudes, so rebalancing happens with room to
* spare rather than at the moment of failure.
*/
export const MIN_GAP = 1e-6;
/**
* The position for a card dropped between `before` and `after`.
* Pass null for either side to mean "no neighbour there".
*/
export function positionBetween(
before: number | null,
after: number | null
): number {
if (before === null && after === null) return SPACING;
if (before === null) return after! - SPACING;
if (after === null) return before + SPACING;
if (before >= after) {
throw new Error(
`positionBetween: neighbours out of order (before=${before}, after=${after})`
);
}
return (before + after) / 2;
}
/** True when any adjacent pair has drifted too close to stay distinguishable. */
export function needsRebalance(positions: number[]): boolean {
const sorted = [...positions].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] - sorted[i - 1] < MIN_GAP) return true;
}
return false;
}
/**
* Evenly spaced positions for a column, given its card ids in display order.
* The caller writes these back; order is preserved exactly.
*/
export function rebalance(
orderedIds: string[]
): Array<{ id: string; position: number }> {
return orderedIds.map((id, index) => ({
id,
position: (index + 1) * SPACING,
}));
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 12 tests across 3 suites.
- Step 5: Commit
git add src/lib/ordering vitest.config.ts
git commit -m "feat(ordering): fractional card positions with rebalance guard"
Task 3: PocketBase service
Files:
-
Create:
pocketbase/directory (binary downloaded, not committed) -
Modify:
README.md -
Step 1: Download the PocketBase binary
mkdir -p pocketbase && cd pocketbase
# macOS arm64. Substitute the matching asset for other platforms.
curl -L -o pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.30.0/pocketbase_0.30.0_darwin_arm64.zip
unzip -o pb.zip pocketbase && rm pb.zip && chmod +x pocketbase
./pocketbase --version
Expected: pocketbase version 0.30.0
- Step 2: Start the server and create the superuser
cd pocketbase && ./pocketbase superuser upsert admin@example.com change-me-please
npm run pb
Expected: the server logs Server started at http://127.0.0.1:8090. Leave it running in its own terminal for the remainder of this plan.
- Step 3: Copy the environment file
cp .env.example .env.local
Set PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD to the values used in Step 2.
- Step 4: Write
README.md
# Client Board
Project tracking for client work. Next.js + PocketBase.
## Running locally
Two processes, two terminals:
```bash
npm run pb # PocketBase on :8090
npm run dev # Next.js on :3000
Migrations in pocketbase/pb_migrations/ apply automatically when PocketBase starts.
Tests
npm test # unit tests, no PocketBase needed
npm run test:integration # requires `npm run pb` to be running
npm run test:all
Seeding demo data
npm run seed
Creates one client, one project with its default columns, one client-manager account, and a handful of cards.
Specs and plans
docs/superpowers/specs/— design documentsdocs/superpowers/plans/— implementation plans
- [ ] **Step 5: Commit**
```bash
git add README.md .gitignore
git commit -m "chore(pb): PocketBase 0.30 service and local run instructions"
Task 4: Migration — clients and projects
Implements the clients and projects collections from spec §4.
Files:
- Create:
pocketbase/pb_migrations/1789600100_created_clients.js - Create:
pocketbase/pb_migrations/1789600200_created_projects.js
Rules are deliberately left permissive-for-authenticated at this stage and tightened in Task 9, so that each migration stays reviewable on its own.
- Step 1: Create the
clientsmigration
pocketbase/pb_migrations/1789600100_created_clients.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `clients` — the companies the developer works for.
*
* A client owns projects. Client-manager users are scoped to exactly one
* client, and that scoping is what stops one customer seeing another's work.
*/
migrate(
(app) => {
const collection = new Collection({
name: "clients",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "name",
type: "text",
required: true,
max: 120,
presentable: true,
},
{
name: "slug",
type: "text",
required: true,
max: 60,
pattern: "^[a-z0-9-]+$",
},
{ name: "brand_color", type: "text", required: false, max: 7 },
{ name: "active", type: "bool", required: false },
{
name: "created",
type: "autodate",
onCreate: true,
onUpdate: false,
},
{
name: "updated",
type: "autodate",
onCreate: true,
onUpdate: true,
},
],
indexes: ["CREATE UNIQUE INDEX idx_clients_slug ON clients (slug)"],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("clients"));
}
);
- Step 2: Create the
projectsmigration
pocketbase/pb_migrations/1789600200_created_projects.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `projects` — one project maps to exactly one git repository.
*
* The three repo_* fields are written now and read for the first time in
* Phase 2, when Forgejo commit ingestion is built. They are stored here so
* the owner can fill them in as projects are created, rather than needing a
* migration and a backfill later.
*/
migrate(
(app) => {
const clients = app.findCollectionByNameOrId("clients");
const collection = new Collection({
name: "projects",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "client",
type: "relation",
required: true,
collectionId: clients.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
{
name: "name",
type: "text",
required: true,
max: 120,
presentable: true,
},
{
name: "slug",
type: "text",
required: true,
max: 60,
pattern: "^[a-z0-9-]+$",
},
{ name: "description", type: "text", required: false, max: 20000 },
{
name: "status",
type: "select",
required: true,
maxSelect: 1,
values: ["active", "paused", "completed", "archived"],
},
{ name: "color", type: "text", required: false, max: 7 },
{ name: "repo_owner", type: "text", required: false, max: 120 },
{ name: "repo_name", type: "text", required: false, max: 120 },
{ name: "repo_url", type: "url", required: false },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_projects_client_slug ON projects (client, slug)",
],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("projects"));
}
);
- Step 3: Restart PocketBase and verify the migrations applied
Stop and restart npm run pb.
Expected log lines: Applied 1789600100_created_clients.js and Applied 1789600200_created_projects.js.
- Step 4: Verify in the admin UI
Open http://127.0.0.1:8090/_/, sign in as the superuser, and confirm clients and projects appear in the collection list with the fields above.
- Step 5: Commit
git add pocketbase/pb_migrations
git commit -m "feat(schema): clients and projects collections"
Task 5: Migration — user roles and columns
Files:
-
Create:
pocketbase/pb_migrations/1789600300_extended_users.js -
Create:
pocketbase/pb_migrations/1789600400_created_columns.js -
Step 1: Extend the
userscollection
pocketbase/pb_migrations/1789600300_extended_users.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* Add `role` and `client` to the built-in `users` auth collection.
*
* `role` is the hinge of the whole permission model: every API rule in
* 1789600900_applied_api_rules.js reads it, and the server route handlers
* refuse to act for any role other than client_manager.
*
* A client_manager MUST have `client` set. That is enforced in application
* code and by the seed script rather than by the schema, because PocketBase
* cannot express "required only when another field has a given value".
*/
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const clients = app.findCollectionByNameOrId("clients");
users.fields.add(
new Field({
name: "role",
type: "select",
required: true,
maxSelect: 1,
values: ["owner", "client_manager"],
})
);
users.fields.add(
new Field({
name: "client",
type: "relation",
required: false,
collectionId: clients.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
})
);
return app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
for (const name of ["role", "client"]) {
const field = users.fields.find((f) => f.name === name);
if (field) users.fields.removeById(field.id);
}
return app.save(users);
}
);
- Step 2: Create the
columnsmigration
pocketbase/pb_migrations/1789600400_created_columns.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `columns` — the vertical lanes of one project's board.
*
* Two flags carry behaviour rather than presentation:
* is_inbox — where client-created cards land. Exactly one per project.
* is_done — the terminal lane. Exactly one per project.
*
* "Exactly one" is enforced in application code (src/lib/columns/seed.ts and
* the admin flows in Plan 1b), not by the schema. PocketBase has no
* cross-record constraint that can express it.
*/
migrate(
(app) => {
const projects = app.findCollectionByNameOrId("projects");
const collection = new Collection({
name: "columns",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "project",
type: "relation",
required: true,
collectionId: projects.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{ name: "name", type: "text", required: true, max: 60, presentable: true },
{ name: "position", type: "number", required: true },
{ name: "is_inbox", type: "bool", required: false },
{ name: "is_done", type: "bool", required: false },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE INDEX idx_columns_project ON columns (project, position)",
],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("columns"));
}
);
- Step 3: Restart PocketBase and verify
Restart npm run pb. Expected: both migrations logged as applied. In the admin UI, users now shows role and client, and columns exists.
- Step 4: Commit
git add pocketbase/pb_migrations
git commit -m "feat(schema): user roles and board columns"
Task 6: Migration — cards, subtasks, comments
Files:
-
Create:
pocketbase/pb_migrations/1789600500_created_cards.js -
Create:
pocketbase/pb_migrations/1789600600_created_subtasks_comments.js -
Step 1: Create the
cardsmigration
pocketbase/pb_migrations/1789600500_created_cards.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `cards` — units of work.
*
* Two fields belong to the client, not the developer:
* priority — the client sets it directly
* target_date — the client sets it directly
* Everything else is the developer's. That split is enforced by the field
* whitelist in src/lib/cards/client-patch.ts, because PocketBase rules are
* record-level and cannot express it. See the domain primer at the top of
* this plan.
*
* Two fields are written now and first read in Phase 2:
* start_date — bounds which commits a scope rule may claim, so a new card
* cannot retroactively swallow a year of old commits.
* scope_rule — the conventional-commit scope this card owns, e.g. "stock"
* matching `feat(stock): ...`.
*/
migrate(
(app) => {
const projects = app.findCollectionByNameOrId("projects");
const columns = app.findCollectionByNameOrId("columns");
const users = app.findCollectionByNameOrId("users");
const collection = new Collection({
name: "cards",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "project",
type: "relation",
required: true,
collectionId: projects.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{
name: "column",
type: "relation",
required: true,
collectionId: columns.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
{ name: "title", type: "text", required: true, max: 200, presentable: true },
{ name: "description", type: "text", required: false, max: 50000 },
{ name: "position", type: "number", required: true },
{
name: "priority",
type: "select",
required: true,
maxSelect: 1,
values: ["critical", "high", "medium", "low"],
},
{ name: "target_date", type: "date", required: false },
{
name: "created_by",
type: "relation",
required: false,
collectionId: users.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
{
name: "created_by_role",
type: "select",
required: true,
maxSelect: 1,
values: ["owner", "client"],
},
{ name: "accepted", type: "bool", required: false },
{ name: "start_date", type: "date", required: false },
{ name: "scope_rule", type: "text", required: false, max: 60 },
{ name: "archived", type: "bool", required: false },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE INDEX idx_cards_column ON cards (`column`, position)",
"CREATE INDEX idx_cards_project ON cards (project, archived)",
],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("cards"));
}
);
Note the backticks around `column` in the index — column is a reserved word in SQLite and an unquoted index on it fails to create.
- Step 2: Create the
subtasksandcommentsmigration
pocketbase/pb_migrations/1789600600_created_subtasks_comments.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `subtasks` and `comments`, both children of a card and both cascade-deleted
* with it.
*
* Comment bodies are markdown authored by a client. They are rendered without
* raw HTML — see Plan 1b. Never render a comment body with
* dangerouslySetInnerHTML.
*/
migrate(
(app) => {
const cards = app.findCollectionByNameOrId("cards");
const users = app.findCollectionByNameOrId("users");
const subtasks = new Collection({
name: "subtasks",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "card",
type: "relation",
required: true,
collectionId: cards.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{ name: "title", type: "text", required: true, max: 200, presentable: true },
{ name: "done", type: "bool", required: false },
{ name: "position", type: "number", required: true },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE INDEX idx_subtasks_card ON subtasks (card, position)"],
});
app.save(subtasks);
const comments = new Collection({
name: "comments",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "card",
type: "relation",
required: true,
collectionId: cards.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{
name: "author",
type: "relation",
required: true,
collectionId: users.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
{ name: "body", type: "text", required: true, max: 20000 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE INDEX idx_comments_card ON comments (card, created)"],
});
return app.save(comments);
},
(app) => {
app.delete(app.findCollectionByNameOrId("comments"));
return app.delete(app.findCollectionByNameOrId("subtasks"));
}
);
- Step 3: Restart PocketBase and verify
Restart npm run pb. Expected: both migrations logged as applied, and cards, subtasks, comments visible in the admin UI. If the cards migration errors on the index, confirm the backticks from Step 1 are present.
- Step 4: Commit
git add pocketbase/pb_migrations
git commit -m "feat(schema): cards, subtasks and comments collections"
Task 7: Migration — activity log and settings
Files:
-
Create:
pocketbase/pb_migrations/1789600700_created_activity.js -
Create:
pocketbase/pb_migrations/1789600800_created_settings.js -
Step 1: Create the
activitymigration
pocketbase/pb_migrations/1789600700_created_activity.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `activity` — an append-only audit log.
*
* This is not a nice-to-have. Once a client can change priorities and dates
* on the developer's board, "I never agreed to that date" has to be
* answerable from the record. Every client-originated write produces a row.
*
* Nothing updates or deletes activity rows. Both rules stay null forever,
* which in PocketBase means superuser-only.
*/
migrate(
(app) => {
const projects = app.findCollectionByNameOrId("projects");
const cards = app.findCollectionByNameOrId("cards");
const users = app.findCollectionByNameOrId("users");
const collection = new Collection({
name: "activity",
type: "base",
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "project",
type: "relation",
required: true,
collectionId: projects.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{
name: "card",
type: "relation",
required: false,
collectionId: cards.id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{
name: "actor",
type: "relation",
required: true,
collectionId: users.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
{
name: "action",
type: "select",
required: true,
maxSelect: 1,
values: [
"card_created",
"card_moved",
"card_updated",
"priority_changed",
"date_changed",
"card_accepted",
"comment_added",
"subtask_toggled",
"card_archived",
],
},
{ name: "field", type: "text", required: false, max: 60 },
{ name: "old_value", type: "text", required: false, max: 2000 },
{ name: "new_value", type: "text", required: false, max: 2000 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
],
indexes: [
"CREATE INDEX idx_activity_card ON activity (card, created)",
"CREATE INDEX idx_activity_project ON activity (project, created)",
],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("activity"));
}
);
- Step 2: Create the
settingsmigration
pocketbase/pb_migrations/1789600800_created_settings.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* `settings` — a single-record collection for AI provider configuration.
*
* Created empty in Phase 1 so Phase 3 needs no migration. Nothing reads these
* fields yet.
*
* EVERY rule is null, meaning superuser-only. This is deliberate and must not
* be relaxed: ai_api_key is a live credential and no browser, owner's
* included, should ever be able to fetch it. Phase 3 reads it server-side
* only, and the settings UI shows it masked and write-only.
*
* ai_system_prompt_extra is APPENDED to a non-editable preamble held in code.
* The guaranteed-present rules — the assistant proposes dates but never
* commits to them, and client-submitted text is data rather than instructions
* — cannot live in an editable field. Precedent: the arithmetic ban in
* MEGAIFRASTRUCTURE/src/lib/chat/system-prompt.ts.
*/
migrate(
(app) => {
const collection = new Collection({
name: "settings",
type: "base",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "ai_provider",
type: "select",
required: false,
maxSelect: 1,
values: ["anthropic", "openai", "minimax"],
},
{ name: "ai_api_key", type: "text", required: false, max: 400 },
{ name: "ai_model", type: "text", required: false, max: 120 },
{
name: "ai_system_prompt_extra",
type: "text",
required: false,
max: 20000,
},
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
return app.save(collection);
},
(app) => {
return app.delete(app.findCollectionByNameOrId("settings"));
}
);
- Step 3: Restart PocketBase and verify
Restart npm run pb. Expected: both applied; activity and settings present in the admin UI.
- Step 4: Commit
git add pocketbase/pb_migrations
git commit -m "feat(schema): activity audit log and AI settings slot"
Task 8: PocketBase connection modules and record types
Three PocketBase instances with three different privilege levels. Keeping them in separate files makes it obvious at every import site which one is in play — a single shared instance is how a superuser token ends up in a browser bundle.
Files:
-
Create:
src/lib/pb/types.ts,src/lib/pb/browser.ts,src/lib/pb/server.ts,src/lib/pb/admin.ts -
Create:
src/lib/http/json.ts -
Step 1: Create the record types
src/lib/pb/types.ts:
/** Record shapes for every collection. Mirrors pocketbase/pb_migrations/. */
export type Role = "owner" | "client_manager";
export type Priority = "critical" | "high" | "medium" | "low";
export type ProjectStatus = "active" | "paused" | "completed" | "archived";
export type CreatedByRole = "owner" | "client";
export type ActivityAction =
| "card_created"
| "card_moved"
| "card_updated"
| "priority_changed"
| "date_changed"
| "card_accepted"
| "comment_added"
| "subtask_toggled"
| "card_archived";
interface BaseRecord {
id: string;
created: string;
updated: string;
}
export interface UserRecord extends BaseRecord {
email: string;
name: string;
role: Role;
/** Empty string when role is "owner". */
client: string;
}
export interface ClientRecord extends BaseRecord {
name: string;
slug: string;
brand_color: string;
active: boolean;
}
export interface ProjectRecord extends BaseRecord {
client: string;
name: string;
slug: string;
description: string;
status: ProjectStatus;
color: string;
repo_owner: string;
repo_name: string;
repo_url: string;
}
export interface ColumnRecord extends BaseRecord {
project: string;
name: string;
position: number;
is_inbox: boolean;
is_done: boolean;
}
export interface CardRecord extends BaseRecord {
project: string;
column: string;
title: string;
description: string;
position: number;
priority: Priority;
/** ISO date string, or "" when unset. */
target_date: string;
created_by: string;
created_by_role: CreatedByRole;
accepted: boolean;
start_date: string;
scope_rule: string;
archived: boolean;
}
export interface SubtaskRecord extends BaseRecord {
card: string;
title: string;
done: boolean;
position: number;
}
export interface CommentRecord extends BaseRecord {
card: string;
author: string;
body: string;
}
export interface ActivityRecord extends Omit<BaseRecord, "updated"> {
project: string;
card: string;
actor: string;
action: ActivityAction;
field: string;
old_value: string;
new_value: string;
}
- Step 2: Create the edge-safe cookie module
src/lib/auth/cookie.ts:
/**
* The auth cookie's name, and nothing else.
*
* This lives alone because `src/middleware.ts` needs it, and middleware runs
* in the Edge runtime. Importing it from `src/lib/pb/server.ts` would drag in
* `server-only` and `next/headers`, neither of which exists on the edge, and
* the build fails. A single exported string has no such problem.
*/
export const AUTH_COOKIE = "pb_auth";
- Step 3: Create the three PocketBase instances
src/lib/pb/browser.ts:
"use client";
import PocketBase from "pocketbase";
/**
* Browser-side PocketBase, carrying the signed-in user's own token.
*
* Reads only. Every write a client manager makes goes through a route under
* /api/client/, because PocketBase rules cannot restrict writes to individual
* fields. See the domain primer in the Phase 1a plan.
*/
export const pbBrowser = new PocketBase(
process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090"
);
src/lib/pb/server.ts:
import "server-only";
import PocketBase from "pocketbase";
import { cookies } from "next/headers";
import { AUTH_COOKIE } from "@/lib/auth/cookie";
import type { UserRecord } from "./types";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
export { AUTH_COOKIE };
/**
* A request-scoped PocketBase instance authenticated as the signed-in user,
* built fresh per request. Never share one across requests: PocketBase's auth
* store is instance state, and a shared instance leaks one user's token into
* another user's request.
*/
export async function getServerPb(): Promise<PocketBase> {
const pb = new PocketBase(PB_URL);
const cookieStore = await cookies();
const raw = cookieStore.get(AUTH_COOKIE);
if (raw) {
pb.authStore.loadFromCookie(`${AUTH_COOKIE}=${raw.value}`, AUTH_COOKIE);
}
return pb;
}
/** The signed-in user, or null. Does not throw. */
export async function getCurrentUser(): Promise<UserRecord | null> {
const pb = await getServerPb();
if (!pb.authStore.isValid || !pb.authStore.record) return null;
return pb.authStore.record as unknown as UserRecord;
}
src/lib/pb/admin.ts:
import "server-only";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
/**
* A PocketBase instance authenticated as a superuser, which bypasses all API
* rules.
*
* This exists for exactly one reason: client-manager writes need field-level
* permission, PocketBase rules are record-level, so the whitelisting happens
* in our route handlers and those handlers need a token that can write.
*
* Every call site MUST have already checked authorization itself. This module
* checks nothing.
*/
export async function getAdminPb(): Promise<PocketBase> {
const email = process.env.PB_SUPERUSER_EMAIL;
const password = process.env.PB_SUPERUSER_PASSWORD;
if (!email || !password) {
throw new Error(
"PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD must be set. Copy .env.example to .env.local."
);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(email, password);
return pb;
}
- Step 4: Create the JSON response helper
src/lib/http/json.ts:
/** Consistent JSON responses so route handlers do not each invent a shape. */
export function jsonOk<T>(data: T, status = 200): Response {
return Response.json(data, { status });
}
export function jsonError(message: string, status: number): Response {
return Response.json({ error: message }, { status });
}
- Step 5: Install
server-onlyand verify the build
npm install server-only
npm run build
Expected: build succeeds. server-only makes an accidental import of admin.ts from a client component a build-time error rather than a shipped credential.
- Step 6: Commit
git add src/lib/pb src/lib/http src/lib/auth/cookie.ts package.json package-lock.json
git commit -m "feat(pb): browser, server and superuser PocketBase clients"
Task 9: Migration — API rules
This is the security boundary. It is a separate task, and a separate commit, so it can be reviewed on its own.
Files:
-
Create:
pocketbase/pb_migrations/1789600900_applied_api_rules.js -
Step 1: Write the rules migration
pocketbase/pb_migrations/1789600900_applied_api_rules.js:
/// <reference path="../pb_data/types.d.ts" />
/**
* The permission boundary.
*
* Reads: scoped by role. An owner reads everything; a client_manager reads
* only records belonging to their own client, via relation traversal.
*
* Writes: allowed for the owner, denied for everyone else. A client_manager's
* token is refused by PocketBase outright. Their writes arrive instead
* through /api/client/* route handlers that authenticate as a superuser and
* copy across only whitelisted fields, because PocketBase rules are
* record-level and cannot say "priority yes, column no".
*
* Do not relax a write rule here to make a client-side form work. That form
* is meant to POST to a route handler.
*
* Rule value meanings in PocketBase, which are easy to confuse:
* null -> superuser only
* "" -> anyone, including unauthenticated
*/
migrate(
(app) => {
const OWNER = '@request.auth.role = "owner"';
/** Read rule for a collection reachable from a project relation. */
const scopedRead = (pathToClient) =>
`@request.auth.id != "" && (${OWNER} || ${pathToClient} = @request.auth.client)`;
const apply = (name, { list, view, create, update, del }) => {
const c = app.findCollectionByNameOrId(name);
c.listRule = list;
c.viewRule = view;
c.createRule = create;
c.updateRule = update;
c.deleteRule = del;
app.save(c);
};
apply("clients", {
list: `@request.auth.id != "" && (${OWNER} || id = @request.auth.client)`,
view: `@request.auth.id != "" && (${OWNER} || id = @request.auth.client)`,
create: OWNER,
update: OWNER,
del: OWNER,
});
apply("projects", {
list: scopedRead("client"),
view: scopedRead("client"),
create: OWNER,
update: OWNER,
del: OWNER,
});
apply("columns", {
list: scopedRead("project.client"),
view: scopedRead("project.client"),
create: OWNER,
update: OWNER,
del: OWNER,
});
apply("cards", {
list: scopedRead("project.client"),
view: scopedRead("project.client"),
create: OWNER,
update: OWNER,
del: OWNER,
});
apply("subtasks", {
list: scopedRead("card.project.client"),
view: scopedRead("card.project.client"),
create: OWNER,
update: OWNER,
del: OWNER,
});
apply("comments", {
list: scopedRead("card.project.client"),
view: scopedRead("card.project.client"),
create: OWNER,
update: OWNER,
del: OWNER,
});
// Activity is append-only and never written from a browser: even the
// owner's creates go through server code, so create stays superuser-only.
apply("activity", {
list: scopedRead("project.client"),
view: scopedRead("project.client"),
create: null,
update: null,
del: null,
});
// settings keeps every rule null — superuser only. See its migration.
return true;
},
(app) => {
const open = '@request.auth.id != ""';
for (const name of [
"clients",
"projects",
"columns",
"cards",
"subtasks",
"comments",
"activity",
]) {
const c = app.findCollectionByNameOrId(name);
c.listRule = open;
c.viewRule = open;
c.createRule = null;
c.updateRule = null;
c.deleteRule = null;
app.save(c);
}
return true;
}
);
- Step 2: Restart PocketBase and verify
Restart npm run pb. Expected: Applied 1789600900_applied_api_rules.js.
In the admin UI, open cards → API Rules. Confirm Update reads @request.auth.role = "owner" and List reads the scoped expression.
- Step 3: Commit
git add pocketbase/pb_migrations
git commit -m "feat(schema): role-scoped read rules and owner-only write rules"
Task 10: Access predicates
Pure functions, no database, so the permission logic can be tested exhaustively and read in one screen. The route handlers in Tasks 14–16 call these; they never re-derive the logic inline.
Files:
-
Create:
src/lib/auth/access.ts -
Test:
src/lib/auth/access.test.ts -
Step 1: Write the failing tests
src/lib/auth/access.test.ts:
import { describe, it, expect } from "vitest";
import {
AccessError,
type Session,
canReadClient,
canWriteClientFields,
assertClientOwnsProject,
} from "@/lib/auth/access";
const owner: Session = { userId: "u_owner", role: "owner", clientId: null };
const wfManager: Session = {
userId: "u_wf",
role: "client_manager",
clientId: "c_wf",
};
const megaManager: Session = {
userId: "u_mega",
role: "client_manager",
clientId: "c_mega",
};
describe("canReadClient", () => {
it("lets the owner read any client", () => {
expect(canReadClient(owner, "c_wf")).toBe(true);
expect(canReadClient(owner, "c_mega")).toBe(true);
});
it("lets a manager read only their own client", () => {
expect(canReadClient(wfManager, "c_wf")).toBe(true);
expect(canReadClient(wfManager, "c_mega")).toBe(false);
});
it("refuses a manager with no client assigned", () => {
const orphan: Session = {
userId: "u_x",
role: "client_manager",
clientId: null,
};
expect(canReadClient(orphan, "c_wf")).toBe(false);
});
});
describe("canWriteClientFields", () => {
it("is true for a manager on their own client", () => {
expect(canWriteClientFields(wfManager, "c_wf")).toBe(true);
});
it("is false for a manager on someone else's client", () => {
expect(canWriteClientFields(wfManager, "c_mega")).toBe(false);
});
it("is false for the owner, who writes through PocketBase directly", () => {
expect(canWriteClientFields(owner, "c_wf")).toBe(false);
});
});
describe("assertClientOwnsProject", () => {
it("passes when the project belongs to the manager's client", () => {
expect(() =>
assertClientOwnsProject(wfManager, { id: "p1", client: "c_wf" })
).not.toThrow();
});
it("throws for another client's project", () => {
expect(() =>
assertClientOwnsProject(megaManager, { id: "p1", client: "c_wf" })
).toThrow(/not found/i);
});
it("reports 'not found' rather than 'forbidden'", () => {
// Saying "forbidden" would confirm the project exists, which tells one
// client that another client's project is real.
try {
assertClientOwnsProject(megaManager, { id: "p1", client: "c_wf" });
expect.unreachable("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(AccessError);
expect((e as AccessError).status).toBe(404);
}
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/auth/access".
- Step 3: Write the implementation
src/lib/auth/access.ts:
/**
* Pure access predicates. No database, no framework — so the permission rules
* can be read in one screen and tested exhaustively.
*
* Route handlers call these. They never re-derive the logic inline, because
* a rule duplicated in five handlers is a rule that will disagree with itself.
*/
import type { Role } from "@/lib/pb/types";
export interface Session {
userId: string;
role: Role;
/** null for the owner; the client id for a client_manager. */
clientId: string | null;
}
export class AccessError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message);
this.name = "AccessError";
}
}
/** May this session read records belonging to `clientId`? */
export function canReadClient(session: Session, clientId: string): boolean {
if (session.role === "owner") return true;
if (!session.clientId) return false;
return session.clientId === clientId;
}
/**
* May this session use the /api/client/* routes against `clientId`?
*
* False for the owner by design: those routes exist to grant a *narrower*
* permission than PocketBase can express. The owner writes to PocketBase
* directly, under the owner-only write rules.
*/
export function canWriteClientFields(
session: Session,
clientId: string
): boolean {
if (session.role !== "client_manager") return false;
if (!session.clientId) return false;
return session.clientId === clientId;
}
/**
* Throw unless the project belongs to this session's client.
*
* Deliberately reports 404, not 403. A 403 would confirm the project exists,
* which tells one customer that another customer's project is real.
*/
export function assertClientOwnsProject(
session: Session,
project: { id: string; client: string }
): void {
if (!canReadClient(session, project.client)) {
throw new AccessError("Project not found", 404);
}
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 9 tests in access.test.ts, plus the 12 from Task 2.
- Step 5: Commit
git add src/lib/auth
git commit -m "feat(auth): pure access predicates for role and client scoping"
Task 11: Client field whitelist
Implements the whitelist half of spec §5.1 and §6. Pure function, built test-first.
Files:
-
Create:
src/lib/cards/client-patch.ts -
Test:
src/lib/cards/client-patch.test.ts -
Step 1: Write the failing tests
src/lib/cards/client-patch.test.ts:
import { describe, it, expect } from "vitest";
import {
CLIENT_EDITABLE_CARD_FIELDS,
pickClientCardPatch,
pickClientCardCreate,
} from "@/lib/cards/client-patch";
describe("pickClientCardPatch", () => {
it("accepts priority and target_date", () => {
const { patch, dropped } = pickClientCardPatch({
priority: "high",
target_date: "2026-10-01",
});
expect(patch).toEqual({ priority: "high", target_date: "2026-10-01" });
expect(dropped).toEqual([]);
});
it("drops every field outside the whitelist", () => {
const { patch, dropped } = pickClientCardPatch({
priority: "low",
column: "col_done",
title: "hacked",
accepted: true,
archived: true,
scope_rule: "stock",
});
expect(patch).toEqual({ priority: "low" });
expect(dropped.sort()).toEqual(
["accepted", "archived", "column", "scope_rule", "title"].sort()
);
});
it("drops a priority outside the allowed set", () => {
const { patch, dropped } = pickClientCardPatch({ priority: "urgent" });
expect(patch).toEqual({});
expect(dropped).toEqual(["priority"]);
});
it("accepts null target_date as clearing the date", () => {
const { patch } = pickClientCardPatch({ target_date: null });
expect(patch).toEqual({ target_date: "" });
});
it("drops a malformed target_date", () => {
const { patch, dropped } = pickClientCardPatch({ target_date: "next tuesday" });
expect(patch).toEqual({});
expect(dropped).toEqual(["target_date"]);
});
it("returns an empty patch for a non-object body", () => {
expect(pickClientCardPatch(null).patch).toEqual({});
expect(pickClientCardPatch("nope").patch).toEqual({});
expect(pickClientCardPatch(42).patch).toEqual({});
});
it("exposes exactly two editable fields", () => {
expect([...CLIENT_EDITABLE_CARD_FIELDS]).toEqual(["priority", "target_date"]);
});
});
describe("pickClientCardCreate", () => {
it("accepts title and description only", () => {
const { input, dropped } = pickClientCardCreate({
title: "Add a POS system",
description: "We need card payments at the counter.",
});
expect(input).toEqual({
title: "Add a POS system",
description: "We need card payments at the counter.",
});
expect(dropped).toEqual([]);
});
it("drops column, accepted and priority on create", () => {
const { input, dropped } = pickClientCardCreate({
title: "x",
column: "col_inprogress",
accepted: true,
priority: "critical",
});
expect(input).toEqual({ title: "x", description: "" });
expect(dropped.sort()).toEqual(["accepted", "column", "priority"].sort());
});
it("rejects a missing or blank title", () => {
expect(pickClientCardCreate({ description: "no title" }).input).toBeNull();
expect(pickClientCardCreate({ title: " " }).input).toBeNull();
});
it("trims the title and caps it at 200 characters", () => {
const { input } = pickClientCardCreate({ title: ` ${"a".repeat(250)} ` });
expect(input!.title).toHaveLength(200);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/cards/client-patch".
- Step 3: Write the implementation
src/lib/cards/client-patch.ts:
/**
* The field whitelist for client-manager writes.
*
* This module is the entire reason /api/client/* exists. PocketBase API rules
* are record-level: there is no rule that says "this user may update priority
* but not column". So a client manager is denied all writes at the database,
* and their requests pass through here instead, where only known-good fields
* survive.
*
* The whitelist is positive, never a blocklist. A new card field added in a
* later phase is inaccessible to clients by default, which is the failure
* direction we want.
*/
import type { Priority } from "@/lib/pb/types";
export const CLIENT_EDITABLE_CARD_FIELDS = ["priority", "target_date"] as const;
const PRIORITIES: readonly Priority[] = ["critical", "high", "medium", "low"];
/** PocketBase stores dates as strings; "" means unset. */
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export interface ClientCardPatch {
priority?: Priority;
target_date?: string;
}
export interface ClientCardCreate {
title: string;
description: string;
}
function asRecord(body: unknown): Record<string, unknown> | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return null;
}
return body as Record<string, unknown>;
}
/**
* Split an untrusted PATCH body into the fields we will apply and the names
* of everything we refused. Dropped names are returned so the caller can log
* them — a client repeatedly sending `column` is worth noticing.
*/
export function pickClientCardPatch(body: unknown): {
patch: ClientCardPatch;
dropped: string[];
} {
const record = asRecord(body);
if (!record) return { patch: {}, dropped: [] };
const patch: ClientCardPatch = {};
const dropped: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (key === "priority") {
if (typeof value === "string" && PRIORITIES.includes(value as Priority)) {
patch.priority = value as Priority;
} else {
dropped.push(key);
}
continue;
}
if (key === "target_date") {
if (value === null || value === "") {
patch.target_date = "";
} else if (typeof value === "string" && ISO_DATE.test(value)) {
patch.target_date = value;
} else {
dropped.push(key);
}
continue;
}
dropped.push(key);
}
return { patch, dropped };
}
/**
* Split an untrusted create body the same way.
*
* Note what is absent: no column, no priority, no accepted. The route handler
* sets those itself — Inbox, medium, false — so a client cannot create a card
* straight into In Progress at critical priority.
*
* `input` is null when the body has no usable title.
*/
export function pickClientCardCreate(body: unknown): {
input: ClientCardCreate | null;
dropped: string[];
} {
const record = asRecord(body);
if (!record) return { input: null, dropped: [] };
const dropped: string[] = [];
let title = "";
let description = "";
for (const [key, value] of Object.entries(record)) {
if (key === "title") {
if (typeof value === "string") title = value.trim().slice(0, 200);
else dropped.push(key);
continue;
}
if (key === "description") {
if (typeof value === "string") description = value.slice(0, 50000);
else dropped.push(key);
continue;
}
// `project` is read from the body by the route handler before calling
// this, so it is expected here and is not a violation.
if (key === "project") continue;
dropped.push(key);
}
if (!title) return { input: null, dropped };
return { input: { title, description }, dropped };
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 11 tests in client-patch.test.ts.
- Step 5: Commit
git add src/lib/cards
git commit -m "feat(cards): positive field whitelist for client-manager writes"
Task 12: Activity diffing
Implements spec §9 test 5. Pure function.
Files:
-
Create:
src/lib/activity/diff.ts -
Test:
src/lib/activity/diff.test.ts -
Step 1: Write the failing tests
src/lib/activity/diff.test.ts:
import { describe, it, expect } from "vitest";
import { buildActivityRows } from "@/lib/activity/diff";
import type { CardRecord } from "@/lib/pb/types";
const card = {
id: "card_1",
project: "proj_1",
priority: "medium",
target_date: "2026-10-01",
} as CardRecord;
describe("buildActivityRows", () => {
it("emits one row per changed field", () => {
const rows = buildActivityRows(card, { priority: "high" }, "u_wf");
expect(rows).toEqual([
{
project: "proj_1",
card: "card_1",
actor: "u_wf",
action: "priority_changed",
field: "priority",
old_value: "medium",
new_value: "high",
},
]);
});
it("emits nothing when the value is unchanged", () => {
expect(buildActivityRows(card, { priority: "medium" }, "u_wf")).toEqual([]);
});
it("emits nothing for an empty patch", () => {
expect(buildActivityRows(card, {}, "u_wf")).toEqual([]);
});
it("uses date_changed for target_date", () => {
const rows = buildActivityRows(card, { target_date: "2026-11-15" }, "u_wf");
expect(rows[0].action).toBe("date_changed");
expect(rows[0].old_value).toBe("2026-10-01");
expect(rows[0].new_value).toBe("2026-11-15");
});
it("records clearing a date as an empty new_value", () => {
const rows = buildActivityRows(card, { target_date: "" }, "u_wf");
expect(rows[0].new_value).toBe("");
expect(rows[0].old_value).toBe("2026-10-01");
});
it("emits one row per field when both change", () => {
const rows = buildActivityRows(
card,
{ priority: "low", target_date: "2026-12-01" },
"u_wf"
);
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.field).sort()).toEqual(["priority", "target_date"]);
});
it("normalises a PocketBase datetime to a plain date before comparing", () => {
// PocketBase returns date fields as "2026-10-01 00:00:00.000Z". Comparing
// that raw against "2026-10-01" would log a change on every save.
const pbCard = {
...card,
target_date: "2026-10-01 00:00:00.000Z",
} as CardRecord;
expect(buildActivityRows(pbCard, { target_date: "2026-10-01" }, "u_wf")).toEqual(
[]
);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/activity/diff".
- Step 3: Write the implementation
src/lib/activity/diff.ts:
/**
* Turn a card patch into audit rows.
*
* Only genuine changes produce rows. A client saving a form without altering
* anything must not generate log noise, or the log stops being readable and
* therefore stops being used.
*/
import type { ActivityAction, CardRecord } from "@/lib/pb/types";
import type { ClientCardPatch } from "@/lib/cards/client-patch";
export interface ActivityRowInput {
project: string;
card: string;
actor: string;
action: ActivityAction;
field: string;
old_value: string;
new_value: string;
}
const ACTION_FOR_FIELD: Record<keyof ClientCardPatch, ActivityAction> = {
priority: "priority_changed",
target_date: "date_changed",
};
/**
* PocketBase returns a date field as "2026-10-01 00:00:00.000Z" but accepts
* "2026-10-01". Compare on the date part alone, or every save looks like an
* edit.
*/
function normalise(value: string): string {
if (!value) return "";
return value.slice(0, 10);
}
export function buildActivityRows(
card: CardRecord,
patch: ClientCardPatch,
actorId: string
): ActivityRowInput[] {
const rows: ActivityRowInput[] = [];
for (const key of Object.keys(patch) as Array<keyof ClientCardPatch>) {
const next = patch[key];
if (next === undefined) continue;
const current = card[key] ?? "";
if (normalise(String(current)) === normalise(String(next))) continue;
rows.push({
project: card.project,
card: card.id,
actor: actorId,
action: ACTION_FOR_FIELD[key],
field: key,
old_value: String(current),
new_value: String(next),
});
}
return rows;
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 7 tests in diff.test.ts.
- Step 5: Commit
git add src/lib/activity
git commit -m "feat(activity): derive audit rows from card patches"
Task 13: Default column seeding
Files:
-
Create:
src/lib/columns/seed.ts -
Test:
src/lib/columns/seed.test.ts -
Step 1: Write the failing tests
src/lib/columns/seed.test.ts:
import { describe, it, expect } from "vitest";
import {
DEFAULT_COLUMNS,
buildColumnRecords,
assertColumnInvariants,
} from "@/lib/columns/seed";
describe("DEFAULT_COLUMNS", () => {
it("is the six-lane board in display order", () => {
expect(DEFAULT_COLUMNS.map((c) => c.name)).toEqual([
"Inbox",
"Backlog",
"To Do",
"In Progress",
"Review",
"Done",
]);
});
it("flags exactly one inbox and one done column", () => {
expect(DEFAULT_COLUMNS.filter((c) => c.is_inbox)).toHaveLength(1);
expect(DEFAULT_COLUMNS.filter((c) => c.is_done)).toHaveLength(1);
});
it("puts the inbox first and done last", () => {
expect(DEFAULT_COLUMNS[0].is_inbox).toBe(true);
expect(DEFAULT_COLUMNS[DEFAULT_COLUMNS.length - 1].is_done).toBe(true);
});
});
describe("buildColumnRecords", () => {
it("attaches the project id and spaced positions", () => {
const records = buildColumnRecords("proj_1");
expect(records).toHaveLength(6);
expect(records.every((r) => r.project === "proj_1")).toBe(true);
expect(records.map((r) => r.position)).toEqual([
1000, 2000, 3000, 4000, 5000, 6000,
]);
});
});
describe("assertColumnInvariants", () => {
it("passes for the default set", () => {
expect(() => assertColumnInvariants(buildColumnRecords("p"))).not.toThrow();
});
it("throws when no column is the inbox", () => {
const cols = buildColumnRecords("p").map((c) => ({ ...c, is_inbox: false }));
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one inbox/i);
});
it("throws when two columns claim to be the inbox", () => {
const cols = buildColumnRecords("p");
cols[1].is_inbox = true;
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one inbox/i);
});
it("throws when no column is done", () => {
const cols = buildColumnRecords("p").map((c) => ({ ...c, is_done: false }));
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one done/i);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — Failed to resolve import "@/lib/columns/seed".
- Step 3: Write the implementation
src/lib/columns/seed.ts:
/**
* The default board layout, and the invariants a board must always satisfy.
*
* `is_inbox` and `is_done` carry behaviour, not styling: client-created cards
* land in the inbox, and the done lane is what later phases will treat as
* completion. A project missing either flag has no valid destination for a
* client's card, so the invariant is checked wherever columns are written.
*/
import { SPACING } from "@/lib/ordering/fractional";
export interface ColumnSeed {
name: string;
is_inbox: boolean;
is_done: boolean;
}
export interface ColumnInput extends ColumnSeed {
project: string;
position: number;
}
export const DEFAULT_COLUMNS: readonly ColumnSeed[] = [
{ name: "Inbox", is_inbox: true, is_done: false },
{ name: "Backlog", is_inbox: false, is_done: false },
{ name: "To Do", is_inbox: false, is_done: false },
{ name: "In Progress", is_inbox: false, is_done: false },
{ name: "Review", is_inbox: false, is_done: false },
{ name: "Done", is_inbox: false, is_done: true },
];
/** The records to create for a new project, in display order. */
export function buildColumnRecords(projectId: string): ColumnInput[] {
return DEFAULT_COLUMNS.map((seed, index) => ({
...seed,
project: projectId,
position: (index + 1) * SPACING,
}));
}
/**
* Throw unless the set has exactly one inbox and exactly one done column.
* Call this before writing any change to a project's columns.
*/
export function assertColumnInvariants(
columns: Array<Pick<ColumnSeed, "is_inbox" | "is_done">>
): void {
const inboxes = columns.filter((c) => c.is_inbox).length;
if (inboxes !== 1) {
throw new Error(
`A project must have exactly one inbox column, found ${inboxes}`
);
}
const dones = columns.filter((c) => c.is_done).length;
if (dones !== 1) {
throw new Error(
`A project must have exactly one done column, found ${dones}`
);
}
}
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — 8 tests in seed.test.ts.
- Step 5: Commit
git add src/lib/columns
git commit -m "feat(columns): default board layout and inbox/done invariants"
Task 14: Authentication
Files:
-
Create:
src/lib/auth/session.ts -
Create:
src/app/api/auth/login/route.ts,src/app/api/auth/logout/route.ts -
Create:
src/app/login/page.tsx -
Create:
src/middleware.ts -
Step 1: Create the session helper
src/lib/auth/session.ts:
import "server-only";
import { getCurrentUser } from "@/lib/pb/server";
import { AccessError, type Session } from "@/lib/auth/access";
/** The current session, or null when signed out. */
export async function getSession(): Promise<Session | null> {
const user = await getCurrentUser();
if (!user) return null;
return {
userId: user.id,
role: user.role,
clientId: user.client || null,
};
}
/** The current session, or throw a 401. For route handlers. */
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) throw new AccessError("Not signed in", 401);
return session;
}
- Step 2: Create the login and logout routes
src/app/api/auth/login/route.ts:
import PocketBase from "pocketbase";
import { AUTH_COOKIE } from "@/lib/auth/cookie";
import { jsonError, jsonOk } from "@/lib/http/json";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
export async function POST(request: Request): Promise<Response> {
let email: unknown;
let password: unknown;
try {
const body = await request.json();
email = body?.email;
password = body?.password;
} catch {
return jsonError("Invalid request body", 400);
}
if (typeof email !== "string" || typeof password !== "string") {
return jsonError("Email and password are required", 400);
}
const pb = new PocketBase(PB_URL);
try {
await pb.collection("users").authWithPassword(email, password);
} catch {
// One message for both "no such user" and "wrong password", so the
// response cannot be used to enumerate accounts.
return jsonError("Invalid email or password", 401);
}
const cookie = pb.authStore.exportToCookie(
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
AUTH_COOKIE
);
const response = jsonOk({ ok: true });
response.headers.append("set-cookie", cookie);
return response;
}
src/app/api/auth/logout/route.ts:
import { AUTH_COOKIE } from "@/lib/auth/cookie";
import { jsonOk } from "@/lib/http/json";
export async function POST(): Promise<Response> {
const response = jsonOk({ ok: true });
response.headers.append(
"set-cookie",
`${AUTH_COOKIE}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax`
);
return response;
}
- Step 3: Create the login page
src/app/login/page.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
setError(null);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
setPending(false);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
setError(body.error ?? "Sign in failed");
return;
}
router.push("/");
router.refresh();
}
return (
<main className="flex min-h-screen items-center justify-center p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-4 rounded-xl border border-neutral-800 bg-neutral-900 p-6"
>
<h1 className="text-lg font-semibold">Sign in</h1>
<label className="block space-y-1">
<span className="text-sm text-neutral-400">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 outline-none focus:border-neutral-500"
/>
</label>
<label className="block space-y-1">
<span className="text-sm text-neutral-400">Password</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 outline-none focus:border-neutral-500"
/>
</label>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-neutral-100 px-3 py-2 font-medium text-neutral-900 disabled:opacity-50"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
</main>
);
}
- Step 4: Create the middleware
src/middleware.ts:
import { NextResponse, type NextRequest } from "next/server";
// From @/lib/auth/cookie, NOT @/lib/pb/server. Middleware runs on the Edge
// runtime, where `server-only` and `next/headers` do not exist.
import { AUTH_COOKIE } from "@/lib/auth/cookie";
/**
* A coarse redirect for signed-out visitors, nothing more.
*
* It checks only that a cookie is present — it does not validate the token and
* must never be relied on for authorization. Every route handler and page
* re-checks the session itself. Middleware runs before the request reaches
* them and is the wrong place to make security decisions.
*/
export function middleware(request: NextRequest) {
const hasAuth = request.cookies.has(AUTH_COOKIE);
if (!hasAuth) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/", "/p/:path*", "/admin/:path*"],
};
- Step 5: Verify manually
With npm run pb and npm run dev both running, visit http://localhost:3000/.
Expected: redirected to /login. Signing in with a non-existent account shows "Invalid email or password". A real account arrives after Task 17; return here and confirm a successful sign-in lands on /.
- Step 6: Commit
git add src/lib/auth/session.ts src/app/api/auth src/app/login src/middleware.ts
git commit -m "feat(auth): PocketBase login, logout and signed-out redirect"
Task 15: PATCH route for card priority and date
The core of spec §6. Implements spec §9 tests 1 and 5.
Files:
-
Create:
src/app/api/client/cards/[id]/route.ts -
Step 1: Write the route handler
src/app/api/client/cards/[id]/route.ts:
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import {
AccessError,
assertClientOwnsProject,
canWriteClientFields,
} from "@/lib/auth/access";
import { pickClientCardPatch } from "@/lib/cards/client-patch";
import { buildActivityRows } from "@/lib/activity/diff";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ProjectRecord } from "@/lib/pb/types";
/**
* PATCH /api/client/cards/:id — a client manager sets priority or target_date.
*
* This route holds a superuser token, so its own checks are the only thing
* between a client and every field on the card. In order:
* 1. a valid session
* 2. the card exists, and its project belongs to this session's client
* 3. the caller is a client_manager for that client
* 4. the body is reduced to the two whitelisted fields
* 5. audit rows are written for whatever actually changed
*
* Do not reorder these, and do not add an early return that skips step 5.
*/
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const session = await requireSession();
const { id } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return jsonError("Invalid request body", 400);
}
const pb = await getAdminPb();
let card: CardRecord;
try {
card = await pb.collection("cards").getOne<CardRecord>(id);
} catch {
return jsonError("Card not found", 404);
}
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(card.project);
// Throws 404 rather than 403: a 403 would confirm the card exists to
// someone who should not know that.
assertClientOwnsProject(session, project);
if (!canWriteClientFields(session, project.client)) {
return jsonError("Card not found", 404);
}
const { patch, dropped } = pickClientCardPatch(body);
if (dropped.length > 0) {
console.warn(
`[client-patch] user=${session.userId} card=${id} dropped=${dropped.join(",")}`
);
}
if (Object.keys(patch).length === 0) {
return jsonError("No editable fields in request", 400);
}
const rows = buildActivityRows(card, patch, session.userId);
const updated = await pb
.collection("cards")
.update<CardRecord>(id, patch);
for (const row of rows) {
await pb.collection("activity").create(row);
}
return jsonOk({
card: {
id: updated.id,
priority: updated.priority,
target_date: updated.target_date,
},
changes: rows.length,
});
} catch (error) {
if (error instanceof AccessError) {
return jsonError(error.message, error.status);
}
console.error("[client-patch] unexpected error", error);
return jsonError("Something went wrong", 500);
}
}
- Step 2: Verify the build
npm run build
Expected: build succeeds and the route list includes /api/client/cards/[id].
- Step 3: Commit
git add src/app/api/client
git commit -m "feat(api): client PATCH route for card priority and target date"
Task 16: Card creation and comment routes
Implements the rest of spec §6, and spec §9 test 3.
Files:
-
Create:
src/app/api/client/cards/route.ts -
Create:
src/app/api/client/cards/[id]/comments/route.ts -
Step 1: Write the card creation route
src/app/api/client/cards/route.ts:
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import {
AccessError,
assertClientOwnsProject,
canWriteClientFields,
} from "@/lib/auth/access";
import { pickClientCardCreate } from "@/lib/cards/client-patch";
import { positionBetween } from "@/lib/ordering/fractional";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
/**
* POST /api/client/cards — a client manager proposes a new card.
*
* The destination column, priority and accepted flag are chosen here, never
* taken from the request. A card always lands in the project's Inbox at
* medium priority with accepted=false, whatever the body asks for. The owner
* accepts it by dragging it out, which is what sets accepted=true.
*/
export async function POST(request: Request): Promise<Response> {
try {
const session = await requireSession();
let body: unknown;
try {
body = await request.json();
} catch {
return jsonError("Invalid request body", 400);
}
const projectId =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).project
: undefined;
if (typeof projectId !== "string" || !projectId) {
return jsonError("A project id is required", 400);
}
const pb = await getAdminPb();
let project: ProjectRecord;
try {
project = await pb
.collection("projects")
.getOne<ProjectRecord>(projectId);
} catch {
return jsonError("Project not found", 404);
}
assertClientOwnsProject(session, project);
if (!canWriteClientFields(session, project.client)) {
return jsonError("Project not found", 404);
}
const { input, dropped } = pickClientCardCreate(body);
if (dropped.length > 0) {
console.warn(
`[client-create] user=${session.userId} project=${projectId} dropped=${dropped.join(",")}`
);
}
if (!input) {
return jsonError("A title is required", 400);
}
const inbox = await pb
.collection("columns")
.getFirstListItem<ColumnRecord>(
`project = "${projectId}" && is_inbox = true`
)
.catch(() => null);
if (!inbox) {
// The column invariants in src/lib/columns/seed.ts should make this
// impossible. If it happens, the project's columns are corrupt and
// silently inventing a destination would hide that.
console.error(`[client-create] project ${projectId} has no inbox column`);
return jsonError("This project cannot accept new cards", 409);
}
const last = await pb
.collection("cards")
.getFirstListItem<CardRecord>(`column = "${inbox.id}"`, {
sort: "-position",
})
.catch(() => null);
const card = await pb.collection("cards").create<CardRecord>({
project: projectId,
column: inbox.id,
title: input.title,
description: input.description,
position: positionBetween(last ? last.position : null, null),
priority: "medium",
created_by: session.userId,
created_by_role: "client",
accepted: false,
archived: false,
});
await pb.collection("activity").create({
project: projectId,
card: card.id,
actor: session.userId,
action: "card_created",
field: "",
old_value: "",
new_value: card.title,
});
return jsonOk({ card: { id: card.id, title: card.title } }, 201);
} catch (error) {
if (error instanceof AccessError) {
return jsonError(error.message, error.status);
}
console.error("[client-create] unexpected error", error);
return jsonError("Something went wrong", 500);
}
}
- Step 2: Write the comment route
src/app/api/client/cards/[id]/comments/route.ts:
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import {
AccessError,
assertClientOwnsProject,
canWriteClientFields,
} from "@/lib/auth/access";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, CommentRecord, ProjectRecord } from "@/lib/pb/types";
/**
* POST /api/client/cards/:id/comments
*
* The body is stored as raw markdown and is never trusted. Plan 1b renders it
* with react-markdown and no raw-HTML plugin; nothing may pass it to
* dangerouslySetInnerHTML.
*/
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const session = await requireSession();
const { id } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return jsonError("Invalid request body", 400);
}
const text =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).body
: undefined;
if (typeof text !== "string" || !text.trim()) {
return jsonError("A comment body is required", 400);
}
const pb = await getAdminPb();
let card: CardRecord;
try {
card = await pb.collection("cards").getOne<CardRecord>(id);
} catch {
return jsonError("Card not found", 404);
}
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(card.project);
assertClientOwnsProject(session, project);
if (!canWriteClientFields(session, project.client)) {
return jsonError("Card not found", 404);
}
const comment = await pb.collection("comments").create<CommentRecord>({
card: id,
author: session.userId,
body: text.trim().slice(0, 20000),
});
await pb.collection("activity").create({
project: card.project,
card: id,
actor: session.userId,
action: "comment_added",
field: "",
old_value: "",
new_value: "",
});
return jsonOk({ comment: { id: comment.id } }, 201);
} catch (error) {
if (error instanceof AccessError) {
return jsonError(error.message, error.status);
}
console.error("[client-comment] unexpected error", error);
return jsonError("Something went wrong", 500);
}
}
- Step 3: Verify the build
npm run build
Expected: build succeeds; the route list includes /api/client/cards and /api/client/cards/[id]/comments.
- Step 4: Commit
git add src/app/api/client
git commit -m "feat(api): client routes for inbox card creation and comments"
Task 17: Demo seed script
Creates the fixtures the integration tests and manual checks both need.
Files:
-
Create:
scripts/seed-demo.ts -
Step 1: Write the seed script
scripts/seed-demo.ts:
/**
* Seed a demo dataset: one client, one project with default columns, an owner,
* a client manager, and a few cards.
*
* Idempotent — re-running updates the existing records rather than duplicating
* them, so it is safe to run between test runs.
*
* Run with: npm run seed (PocketBase must be running)
*/
import PocketBase from "pocketbase";
import { buildColumnRecords, assertColumnInvariants } from "../src/lib/columns/seed";
import { SPACING } from "../src/lib/ordering/fractional";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
export const DEMO = {
clientSlug: "wf",
projectSlug: "wfdash-webapp",
ownerEmail: "owner@example.com",
ownerPassword: "owner-password-123",
managerEmail: "manager@wf.example.com",
managerPassword: "manager-password-123",
};
async function upsert<T extends { id: string }>(
pb: PocketBase,
collection: string,
filter: string,
data: Record<string, unknown>
): Promise<T> {
const existing = await pb
.collection(collection)
.getFirstListItem<T>(filter)
.catch(() => null);
if (existing) {
return pb.collection(collection).update<T>(existing.id, data);
}
return pb.collection(collection).create<T>(data);
}
async function main() {
const email = process.env.PB_SUPERUSER_EMAIL;
const password = process.env.PB_SUPERUSER_PASSWORD;
if (!email || !password) {
throw new Error(
"PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD must be set. Copy .env.example to .env.local and export them."
);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(email, password);
const client = await upsert<{ id: string }>(
pb,
"clients",
`slug = "${DEMO.clientSlug}"`,
{
name: "WF",
slug: DEMO.clientSlug,
brand_color: "#3b82f6",
active: true,
}
);
const project = await upsert<{ id: string }>(
pb,
"projects",
`slug = "${DEMO.projectSlug}"`,
{
client: client.id,
name: "WFDash Web App",
slug: DEMO.projectSlug,
description: "Operations web application.",
status: "active",
color: "#3b82f6",
repo_owner: "David",
repo_name: "WFDASH-WEBAPP",
}
);
const columnInputs = buildColumnRecords(project.id);
assertColumnInvariants(columnInputs);
const columns: Record<string, string> = {};
for (const input of columnInputs) {
const record = await upsert<{ id: string }>(
pb,
"columns",
`project = "${project.id}" && name = "${input.name}"`,
input
);
columns[input.name] = record.id;
}
await upsert(pb, "users", `email = "${DEMO.ownerEmail}"`, {
email: DEMO.ownerEmail,
password: DEMO.ownerPassword,
passwordConfirm: DEMO.ownerPassword,
name: "Dave",
role: "owner",
verified: true,
});
await upsert(pb, "users", `email = "${DEMO.managerEmail}"`, {
email: DEMO.managerEmail,
password: DEMO.managerPassword,
passwordConfirm: DEMO.managerPassword,
name: "WF Manager",
role: "client_manager",
client: client.id,
verified: true,
});
const cards = [
{ title: "Quote attachments split", column: "In Progress", priority: "high" },
{ title: "Stock inline quantity editing", column: "To Do", priority: "medium" },
{ title: "Delivery note grouping", column: "Done", priority: "low" },
];
let position = SPACING;
for (const card of cards) {
await upsert(
pb,
"cards",
`project = "${project.id}" && title = "${card.title}"`,
{
project: project.id,
column: columns[card.column],
title: card.title,
description: "",
position,
priority: card.priority,
created_by_role: "owner",
accepted: true,
archived: false,
}
);
position += SPACING;
}
console.log("Seeded:");
console.log(` client ${client.id} (${DEMO.clientSlug})`);
console.log(` project ${project.id} (${DEMO.projectSlug})`);
console.log(` owner ${DEMO.ownerEmail} / ${DEMO.ownerPassword}`);
console.log(` manager ${DEMO.managerEmail} / ${DEMO.managerPassword}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
- Step 2: Run the seed
set -a && source .env.local && set +a && npm run seed
Expected output ends with the four "Seeded:" lines listing ids and credentials.
- Step 3: Verify the login flow end to end
With npm run dev running, sign in at http://localhost:3000/login as manager@wf.example.com / manager-password-123.
Expected: redirected to /, showing the placeholder home page. This completes the manual check deferred in Task 14 Step 5.
- Step 4: Commit
git add scripts
git commit -m "chore(seed): demo client, project, users and cards"
Task 18: Integration tests for the permission boundary
Implements spec §9 tests 1, 2, 3 and 5. These run against a live PocketBase and a running Next.js server, because what they verify is precisely the behaviour of the real HTTP boundary — a mocked version would pass while the deployed system stayed wide open.
Files:
-
Create:
tests/integration/helpers.ts -
Create:
tests/integration/client-permissions.test.ts -
Create:
tests/integration/activity-log.test.ts -
Step 1: Write the test helpers
tests/integration/helpers.ts:
import PocketBase from "pocketbase";
export const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
export const APP_URL = process.env.APP_URL ?? "http://localhost:3000";
export const DEMO = {
clientSlug: "wf",
projectSlug: "wfdash-webapp",
managerEmail: "manager@wf.example.com",
managerPassword: "manager-password-123",
};
/**
* Fail loudly rather than skipping. A permission test that silently skips is
* worse than no test: the suite goes green and nobody checks the boundary.
*/
export async function requireServices(): Promise<void> {
const pbUp = await fetch(`${PB_URL}/api/health`)
.then((r) => r.ok)
.catch(() => false);
if (!pbUp) {
throw new Error(
`PocketBase is not reachable at ${PB_URL}. Run \`npm run pb\` first.`
);
}
const appUp = await fetch(`${APP_URL}/login`)
.then((r) => r.ok)
.catch(() => false);
if (!appUp) {
throw new Error(
`The app is not reachable at ${APP_URL}. Run \`npm run dev\` first.`
);
}
}
export async function adminPb(): Promise<PocketBase> {
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb
.collection("_superusers")
.authWithPassword(
process.env.PB_SUPERUSER_EMAIL!,
process.env.PB_SUPERUSER_PASSWORD!
);
return pb;
}
/** Sign in through the app and return the Set-Cookie value for later requests. */
export async function signIn(
email: string,
password: string
): Promise<string> {
const response = await fetch(`${APP_URL}/api/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error(`Sign in failed for ${email}: ${response.status}`);
}
const setCookie = response.headers.get("set-cookie");
if (!setCookie) throw new Error("No auth cookie returned");
return setCookie.split(";")[0];
}
/** A PocketBase client authenticated as a normal user, for read-rule tests. */
export async function userPb(
email: string,
password: string
): Promise<PocketBase> {
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.collection("users").authWithPassword(email, password);
return pb;
}
- Step 2: Write the permission tests
tests/integration/client-permissions.test.ts:
import { describe, it, expect, beforeAll } from "vitest";
import PocketBase from "pocketbase";
import {
APP_URL,
DEMO,
adminPb,
requireServices,
signIn,
userPb,
} from "./helpers";
let admin: PocketBase;
let cookie: string;
let projectId: string;
let cardId: string;
let inboxColumnId: string;
let doneColumnId: string;
beforeAll(async () => {
await requireServices();
admin = await adminPb();
cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
projectId = project.id;
const card = await admin
.collection("cards")
.getFirstListItem(`project = "${projectId}"`);
cardId = card.id;
inboxColumnId = (
await admin
.collection("columns")
.getFirstListItem(`project = "${projectId}" && is_inbox = true`)
).id;
doneColumnId = (
await admin
.collection("columns")
.getFirstListItem(`project = "${projectId}" && is_done = true`)
).id;
});
/** Spec §9 test 1 */
describe("a client manager cannot write non-whitelisted card fields", () => {
it("leaves column, title and accepted untouched", async () => {
const before = await admin.collection("cards").getOne(cardId);
const response = await fetch(`${APP_URL}/api/client/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({
priority: "critical",
column: doneColumnId,
title: "hacked",
accepted: false,
archived: true,
scope_rule: "stock",
}),
});
expect(response.status).toBe(200);
const after = await admin.collection("cards").getOne(cardId);
expect(after.priority).toBe("critical"); // whitelisted, applied
expect(after.column).toBe(before.column); // not whitelisted, unchanged
expect(after.title).toBe(before.title);
expect(after.accepted).toBe(before.accepted);
expect(after.archived).toBe(before.archived);
expect(after.scope_rule).toBe(before.scope_rule);
});
it("refuses a direct PocketBase update with the manager's own token", async () => {
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
await expect(
pb.collection("cards").update(cardId, { title: "hacked directly" })
).rejects.toThrow();
const after = await admin.collection("cards").getOne(cardId);
expect(after.title).not.toBe("hacked directly");
});
});
/** Spec §9 test 2 */
describe("a client manager cannot reach another client's project", () => {
it("returns no records for a foreign project", async () => {
const otherClient = await admin.collection("clients").create({
name: "Mega Infrastructure",
slug: `mega-${Date.now()}`,
active: true,
});
const otherProject = await admin.collection("projects").create({
client: otherClient.id,
name: "Mega Plant",
slug: `mega-plant-${Date.now()}`,
status: "active",
});
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
await expect(
pb.collection("projects").getOne(otherProject.id)
).rejects.toThrow();
const visible = await pb.collection("projects").getFullList();
expect(visible.map((p) => p.id)).not.toContain(otherProject.id);
const response = await fetch(`${APP_URL}/api/client/cards`, {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({ project: otherProject.id, title: "sneaky" }),
});
expect(response.status).toBe(404);
await admin.collection("projects").delete(otherProject.id);
await admin.collection("clients").delete(otherClient.id);
});
});
/** Spec §9 test 3 */
describe("client-created cards land in the Inbox", () => {
it("ignores the requested column, priority and accepted flag", async () => {
const response = await fetch(`${APP_URL}/api/client/cards`, {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({
project: projectId,
title: "Add a POS system",
description: "Card payments at the counter.",
column: doneColumnId,
priority: "critical",
accepted: true,
}),
});
expect(response.status).toBe(201);
const { card } = await response.json();
const created = await admin.collection("cards").getOne(card.id);
expect(created.column).toBe(inboxColumnId);
expect(created.priority).toBe("medium");
expect(created.accepted).toBe(false);
expect(created.created_by_role).toBe("client");
expect(created.title).toBe("Add a POS system");
await admin.collection("cards").delete(card.id);
});
it("rejects a create with no title", async () => {
const response = await fetch(`${APP_URL}/api/client/cards`, {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({ project: projectId, description: "no title" }),
});
expect(response.status).toBe(400);
});
});
describe("unauthenticated requests", () => {
it("are refused without a session cookie", async () => {
const response = await fetch(`${APP_URL}/api/client/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ priority: "low" }),
});
expect(response.status).toBe(401);
});
});
- Step 3: Write the activity log test
tests/integration/activity-log.test.ts:
import { describe, it, expect, beforeAll } from "vitest";
import PocketBase from "pocketbase";
import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers";
let admin: PocketBase;
let cookie: string;
let cardId: string;
beforeAll(async () => {
await requireServices();
admin = await adminPb();
cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
cardId = (
await admin.collection("cards").getFirstListItem(`project = "${project.id}"`)
).id;
});
async function patch(body: Record<string, unknown>) {
return fetch(`${APP_URL}/api/client/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify(body),
});
}
async function activityCount(): Promise<number> {
const rows = await admin
.collection("activity")
.getFullList({ filter: `card = "${cardId}"` });
return rows.length;
}
/** Spec §9 test 5 */
describe("every client-originated change is audited", () => {
it("writes one row with the correct old and new values", async () => {
await admin.collection("cards").update(cardId, { priority: "medium" });
const before = await activityCount();
const response = await patch({ priority: "high" });
expect(response.status).toBe(200);
// Exactly one new row overall — not "at least one", which would pass even
// if the route wrote a row per field it merely looked at.
expect(await activityCount()).toBe(before + 1);
const rows = await admin.collection("activity").getFullList({
filter: `card = "${cardId}" && field = "priority"`,
sort: "-created",
});
expect(rows[0].action).toBe("priority_changed");
expect(rows[0].old_value).toBe("medium");
expect(rows[0].new_value).toBe("high");
expect(rows[0].actor).not.toBe("");
});
it("writes no row when the value is unchanged", async () => {
await admin.collection("cards").update(cardId, { priority: "low" });
const before = await activityCount();
const response = await patch({ priority: "low" });
expect(response.status).toBe(200);
expect(await activityCount()).toBe(before);
});
it("writes one row per field when two change at once", async () => {
await admin
.collection("cards")
.update(cardId, { priority: "medium", target_date: "2026-10-01" });
const before = await activityCount();
const response = await patch({
priority: "critical",
target_date: "2026-12-24",
});
expect(response.status).toBe(200);
expect(await activityCount()).toBe(before + 2);
});
it("returns 400 when the body contains no editable fields", async () => {
const response = await patch({ title: "nope", column: "whatever" });
expect(response.status).toBe(400);
});
});
- Step 4: Run the integration tests
Three terminals: npm run pb, npm run dev, then:
set -a && source .env.local && set +a && npm run test:integration
Expected: PASS — 5 suites, 10 tests.
- Step 5: Run the whole suite
set -a && source .env.local && set +a && npm run test:all
Expected: PASS — 47 unit tests plus 10 integration tests.
Unit test counts by file, for checking nothing was skipped: fractional.test.ts 12, access.test.ts 9, client-patch.test.ts 11, diff.test.ts 7, seed.test.ts 8.
- Step 6: Commit
git add tests vitest.config.ts
git commit -m "test(permissions): integration coverage for the client write boundary"
Definition of done
Plan 1a is complete when all of the following hold:
npm testpasses with no PocketBase running.npm run test:integrationpasses with PocketBase and the dev server running.npm run buildsucceeds.- A client manager can sign in, change a card's priority and target date, and add a card that appears in Inbox with
accepted = falseandcreated_by_role = "client". - A client manager cannot change any other field — verified by the tests in Task 18, not by the absence of a button.
- A client manager cannot read another client's project through either the app or a direct PocketBase query.
- Every client-originated change has an
activityrow with actor, field, old value and new value.
Spec §9's five acceptance tests map to: test 1 → Task 18 Step 2; test 2 → Task 18 Step 2; test 3 → Task 18 Step 2; test 4 → Task 2; test 5 → Task 18 Step 3.
Deliberately not in this plan
Deferred to Plan 1b, which is written after this plan is implemented and reviewed:
- The board UI, drag and drop, and the
PATCHroute for owner card moves - Home page, project switcher, card detail view
- Admin pages for clients, projects and users
- The Inbox accept flow that sets
accepted = trueandstart_date - Realtime subscriptions
- shadcn/ui component installation
Deferred to Phases 2–5 per the spec: Forgejo ingestion, commit linking, effort statistics, AI provider layer and settings UI, the client intake wizard, and the roadmap view.