[
  {
    "file": "docs/code_review.md",
    "before": "# Code Review\n\nReviewed: 2026-03-28\nScope: All source files in `backend/`, `frontend/src/`, `frontend/tests/`, `Dockerfile`, `docker-compose.yml`\n\n---\n\n## Summary\n\n| Area | Critical | High | Medium | Low |\n|------|----------|------|--------|-----|\n| Security | — | 3 | 4 | 1 |\n| Correctness | — | 1 | 2 | 1 |\n| Code Quality | — | — | 2 | 4 |\n| Tests | — | — | 3 | 1 |\n| Architecture | — | 1 | 2 | 1 |\n| Performance | — | — | — | 2 |\n\n---\n\n## Security\n\n### MEDIUM — API key in untracked `.env` with no documentation\n\n**File:** `.env`, `.env.example`\n\n`.env` is correctly gitignored and not in the repository. However, `.env.example` exists but does not document that `OPENROUTER_API_KEY` must be supplied, and there is no README note explaining how to set up the key for a new developer.\n\n**Actions:**\n1. Add the key name (without value) to `.env.example` if not already there.\n2. Note in README or setup docs that a valid `OPENROUTER_API_KEY` is required and where to obtain one.\n\n---\n\n### HIGH — Client-side credential validation\n\n**Files:** `frontend/src/components/LoginScreen.tsx:14`, `frontend/src/lib/auth.ts`\n\nLogin is validated entirely in the browser:\n\n```typescript\nif (username === \"user\" && password === \"password\") {\n```\n\nThe auth token is then stored in `localStorage`. There is no server-side session validation; API endpoints trust the `username` field sent by the client.\n\n**Actions:**\n- Move credential validation to the backend: `POST /api/auth/login` returns a signed token.\n- Validate the token on every API request server-side.\n- Use `httpOnly` cookies instead of `localStorage` (immune to XSS theft).\n\n---\n\n### HIGH — Plaintext password storage and comparison\n\n**Files:** `backend/db.py:62`, `backend/db.py:79`\n\nPasswords are inserted and stored as plaintext strings. Any database read exposes all credentials.\n\n**Actions:**\n- Hash passwords with `bcrypt` or `argon2-cffi` on write; compare hash on login.\n- Never store or log plaintext passwords.\n\n---\n\n### HIGH — No server-side authentication on any API endpoint\n\n**File:** `backend/main.py` (all routes)\n\n`GET /api/kanban`, `PUT /api/kanban`, and `POST /api/ai/kanban` accept a `username` query param or body field with no verification. Any caller can read or overwrite any user's board.\n\n**Actions:**\n- After implementing token-based auth above, validate the token on all `/api/` routes.\n- Derive `username` from the validated token; reject client-supplied usernames.\n- Add rate limiting on `/api/ai/kanban` to cap AI spend per user.\n\n---\n\n### MEDIUM — No input validation on board payload\n\n**File:** `backend/main.py:96-102`\n\n`PUT /api/kanban` and `POST /api/ai/kanban` accept any arbitrary `board` dict. There is no schema check on the structure before it is written to the database.\n\n**Actions:**\n- Define Pydantic models for `Column` and `Card` with required fields and types.\n- Replace `board: Dict[str, Any]` with a typed `BoardData` model so FastAPI validates on arrival.\n\n---\n\n### MEDIUM — AI prompt injection\n\n**File:** `backend/main.py` (system prompt construction)\n\nThe raw user prompt string is embedded directly into the AI system message. A crafted prompt can override the system instructions.\n\n**Actions:**\n- Sanitize prompt input: strip control characters, enforce maximum length.\n- Delimit user content clearly in the prompt (e.g. wrap in XML-style tags).\n- Validate all AI-returned board updates against the Pydantic schema regardless of content.\n\n---\n\n### MEDIUM — No CSRF protection\n\n**File:** `frontend/src/lib/api.ts`\n\nRequests use `credentials: \"same-origin\"` but carry no CSRF token. A malicious page could trigger state-changing requests on behalf of an authenticated user.\n\n**Actions:**\n- Generate a CSRF token on login and include it in a custom header (`X-CSRF-Token`) on all `PUT`/`POST` requests.\n- Validate the token server-side for all state-changing endpoints.\n\n---\n\n### LOW — Internal error details returned to client\n\n**File:** `backend/main.py:375`\n\n`str(exc)` is returned in AI error responses, which can expose stack traces or internal service details.\n\n**Actions:**\n- Log the full exception server-side.\n- Return a generic message to the client: `\"AI service error\"`.\n\n---\n\n## Correctness\n\n### HIGH — SQLite writes may not be committed\n\n**File:** `backend/db.py:159-169`\n\nThe `with get_connection() as connection:` block calls `connection.execute(UPDATE ...)` and `connection.execute(INSERT ...)` but never calls `connection.commit()`. Python's `sqlite3` module does not auto-commit writes when used as a context manager unless `isolation_level` is `None`.\n\n**Actions:**\n- Add `connection.commit()` after the write block, or set `isolation_level=None` on the connection to enable auto-commit.\n- Add a test that writes a board, closes the connection, reopens it, and reads back the written value.\n\n---\n\n### MEDIUM — Race condition on board save\n\n**File:** `frontend/src/components/KanbanBoard.tsx:47-63`\n\n`persistBoard` is called inside the `setBoard` updater function but not awaited. Rapid state changes (e.g. several quick drag-drops) may result in an earlier request overwriting a later one, and the UI gives no indication when a save is in flight or has failed.\n\n**Actions:**\n- Move `persistBoard` outside the `setBoard` call and await it in a `useEffect` that watches `board`.\n- Show a saving indicator while the PUT request is in flight.\n\n---\n\n### MEDIUM — Silent failure in `moveCard`\n\n**File:** `frontend/src/lib/kanban.ts:92-93`\n\nIf `activeColumnId` or `overColumnId` is not found, `moveCard` returns the original columns with no indication of failure:\n\n```typescript\nif (!activeColumnId || !overColumnId) {\n    return columns;\n}\n```\n\n**Actions:**\n- At minimum log a warning so failures are visible during development.\n- Return a typed result (`{ ok: boolean; columns: Column[]; error?: string }`) so the caller can surface an error.\n\n---\n\n### LOW — `createId` uses `Math.random()`\n\n**File:** `frontend/src/lib/kanban.ts:164-168`\n\n`Math.random()` is not cryptographically random. For an ID generator, collisions under rapid creation are possible.\n\n**Actions:**\n- Replace with `crypto.randomUUID()` (available in all modern browsers and Node 19+).\n\n---\n\n## Code Quality\n\n### MEDIUM — Test database not reset between tests\n\n**File:** `backend/test_api.py:17-20`\n\n`setup_database()` is defined but only the module-level call at import time resets the DB. Individual tests share state, so the order in which they run can affect results (e.g. `test_update_kanban_persists` modifies the board that later tests read).\n\n**Actions:**\n- Convert `setup_database` to a pytest fixture with `scope=\"function\"` and apply it to every test via `autouse=True` or explicit parameter.\n\n---\n\n### MEDIUM — Inconsistent error handling in `AIChatSidebar`\n\n**File:** `frontend/src/components/AIChatSidebar.tsx:35-56`\n\nThe component handles two failure modes differently: a `response.success === false` payload and a thrown network error. These lead to slightly different code paths and make the component harder to reason about.\n\n**Actions:**\n- Standardise on one pattern. The simplest: have `askKanbanAI` always throw on failure (both network errors and `success: false` responses), and catch in a single `catch` block.\n\n---\n\n### LOW — Duplicate initial board data\n\n**Files:** `backend/db.py` and `frontend/src/lib/kanban.ts`\n\n`DEFAULT_BOARD` (backend) and `initialData` (frontend) define the same 5-column, 8-card structure. The two can drift silently.\n\n**Actions:**\n- Remove `initialData` from the frontend entirely. On first load the board comes from `GET /api/kanban` which already seeds the default.\n\n---\n\n### LOW — Magic column/card ID strings repeated across files\n\nColumn IDs like `\"col-backlog\"` and card IDs like `\"card-1\"` appear in the frontend lib, the backend default data, and the E2E test. A rename requires touching multiple files.\n\n**Actions:**\n- Extract to a shared constants file on the frontend (`src/lib/constants.ts`).\n- Reference the same strings in the E2E test.\n\n---\n\n### LOW — Unused `drag-repro.js` and `drag-repro-after-login.png` in repo root\n\nThese debugging artefacts are committed at the project root and are not referenced by any code, test, or documentation.\n\n**Actions:**\n- Delete both files and commit the removal.\n\n---\n\n### LOW — Duplicate `.env` entry in `.gitignore`\n\n**File:** `.gitignore:102` and `.gitignore:131`\n\n`.env` appears twice. Remove one.\n\n---\n\n## Tests\n\n### MEDIUM — Several components have no tests\n\nThe following have no unit tests at all:\n\n- `frontend/src/components/NewCardForm.tsx` — form validation, submit, cancel\n- `frontend/src/components/KanbanColumn.tsx` — column rename\n- `frontend/src/components/KanbanCard.tsx` — delete button\n- `frontend/src/lib/api.ts` — network errors, non-200 status codes\n- `frontend/src/lib/auth.ts` — localStorage edge cases\n\n**Actions:**\n- Add unit tests for each. Start with `NewCardForm` and `api.ts` as they cover the most user-facing logic.\n\n---\n\n### MEDIUM — E2E drag-drop test is fragile\n\n**File:** `frontend/tests/kanban.spec.ts:26-48`\n\nThe drag-drop test computes pixel coordinates from bounding boxes and uses hardcoded step counts. It will fail if the viewport size, zoom level, or layout changes. (There is also a committed `drag-repro.js` suggesting this has already been a problem.)\n\n**Actions:**\n- Replace the manual `mouse.move/down/up` sequence with Playwright's `dragTo` API:\n  ```typescript\n  await card.dragTo(targetColumn);\n  ```\n- If `dragTo` is insufficient for dnd-kit, add a `data-testid` drop target and use a custom drag helper that fires pointer events.\n\n---\n\n### MEDIUM — Backend tests lack isolation fixture\n\nRelated to the code quality finding above — covered under that action item.\n\n---\n\n### LOW — Frontend tests mock `fetch` globally without MSW\n\n**File:** `frontend/src/components/KanbanBoard.test.tsx`\n\n`vi.stubGlobal('fetch', ...)` mocks the entire `fetch` function. This can leave the global in a bad state if a test throws before `afterEach` runs.\n\n**Actions:**\n- Use `vi.spyOn(global, 'fetch')` so Vitest restores the original automatically, or adopt Mock Service Worker (MSW) for cleaner HTTP mocking.\n\n---\n\n## Architecture\n\n### HIGH — No backend authentication enforces user boundaries\n\nCovered in the Security section above; restated here because it is also an architectural gap. The API was designed with a `username` field in payloads as a placeholder for future auth, but that mechanism needs to be completed before any multi-user scenario.\n\n---\n\n### MEDIUM — No database transactions around upsert\n\n**File:** `backend/db.py:159-169`\n\nThe `save_board_for_user` function does an UPDATE then conditionally an INSERT. If the process dies between the two statements, the row count check can produce an inconsistent state.\n\n**Actions:**\n- Wrap both statements in an explicit `BEGIN`/`COMMIT` block.\n- Alternatively, replace the two-step logic with an `INSERT OR REPLACE` (upsert) using the unique index on `user_id`.\n\n---\n\n### MEDIUM — Playwright config assumes dev server for E2E but tests need backend\n\n**File:** `frontend/playwright.config.ts`\n\n`webServer` starts `npm run dev` on port 3000, but `api.ts` uses relative URLs with no proxy configured. E2E tests fail unless manually pointed at port 8000 (Docker).\n\n**Actions:**\n- Either add a Next.js rewrites config to proxy `/api/*` to `localhost:8000` during dev, or change the `webServer` in the Playwright config to start the Docker container and point `baseURL` at port 8000.\n\n---\n\n### LOW — No API versioning\n\nAll routes are under `/api/` with no version segment. Breaking changes will break existing clients with no migration path.\n\n**Actions:**\n- Add `/api/v1/` prefix now, while there is only one client. Cheap to do early, expensive later.\n\n---\n\n## Performance\n\n### LOW — Full board serialized on every save\n\n**File:** `backend/db.py:120,141`\n\nThe entire board JSON is serialized on every `PUT`. For the current MVP board size this is negligible, but it is worth noting for future growth.\n\n**Actions:**\n- No immediate action required. If board size grows significantly, consider splitting into `columns` and `cards` tables and using partial updates.\n\n---\n\n### LOW — `cache: \"no-store\"` on every board fetch\n\n**File:** `frontend/src/lib/api.ts:18`\n\nEvery call to `fetchKanban` bypasses the HTTP cache. For a single-user local app this is fine, but it is worth revisiting before any network latency is introduced.\n\n**Actions:**\n- No immediate action required. Document the reason for `no-store` (board must always be fresh) so it is an intentional choice rather than an oversight.\n\n---\n\n## Immediate action checklist\n\n1. Fix SQLite commit issue in `db.py` to prevent silent data loss.\n4. Delete `drag-repro.js` and `drag-repro-after-login.png`.\n5. Remove duplicate initial board data from `frontend/src/lib/kanban.ts`.\n6. Add pytest fixture to reset the database between backend tests.\n7. Plan server-side auth implementation (token issuance, validation, password hashing).\n",
    "after": "# Code Review\n\nReviewed: 2026-03-29\n\nScope: `backend/`, `frontend/src/`, `frontend/tests/`, `Dockerfile`, `docker-compose.yml`, `scripts/`, `docs/`\n\n## Overall\n\n- The project has a solid MVP structure: backend concerns are split cleanly, the frontend board logic is readable, and there is already useful automated coverage.\n- The main risks are AI/state integrity, SQLite durability in Docker, and frontend save concurrency.\n- The existing `docs/code_review.md` was stale and has been replaced.\n\n## Findings\n\n### High - `/api/ai/kanban` trusts client-supplied board state\n\nRefs: `backend/routes/ai.py:52`, `backend/routes/ai.py:55`, `backend/routes/ai.py:63`\n\nThe route confirms the authenticated user has a board, but then sends `request.board` to the AI and persists the AI result. That lets a stale or tampered client ask the AI to operate on arbitrary board state and overwrite the stored board.\n\nRecommended fix:\n- Load the persisted board server-side and use that as the only source of truth.\n- Treat client state as optional context at most, not authoritative data.\n- Validate the AI-returned board before saving it.\n\n### High - Dockerized SQLite data is not durable across container recreation\n\nRefs: `backend/db.py:10`, `docker-compose.yml:1`, `scripts/start-macos.sh:4`, `scripts/start-linux.sh:4`, `scripts/start-windows.ps1:3`\n\nThe database lives inside the container filesystem, and the Compose setup does not declare a volume. A normal rebuild or container recreation can wipe the only stored board data, which conflicts with the persistence goal of the app.\n\nRecommended fix:\n- Add a named volume or bind mount for `backend/kanban.db`.\n- Document that local persistence depends on the mounted volume.\n\n### High - The frontend blindly trusts board payload shape and can crash on inconsistent data\n\nRefs: `frontend/src/lib/api.ts:40`, `frontend/src/lib/api.ts:60`, `frontend/src/components/KanbanBoard.tsx:225`, `frontend/src/components/KanbanColumn.tsx:52`, `frontend/src/components/KanbanCard.tsx:13`\n\nAPI responses are cast directly to `BoardData`. If a board contains a `cardId` that is missing from `cards`, the render path passes `undefined` into card components and can fail at runtime. This is especially risky because the AI path can persist malformed board data.\n\nRecommended fix:\n- Validate board payloads at the API boundary before using them in the UI.\n- Reject or sanitize inconsistent boards before rendering.\n- Add tests for malformed payload handling.\n\n### High - `/api/ai/test` is unauthenticated and can spend real AI quota\n\nRefs: `backend/routes/ai.py:12`, `backend/openrouter.py:16`\n\nThe test route makes a real third-party AI call without auth. If the app is exposed beyond a single trusted local user, anyone can consume quota.\n\nRecommended fix:\n- Require auth on the route, or remove it outside development.\n- Consider a lightweight rate limit for AI endpoints.\n\n### Medium - Board saves race with each other, and AI updates can overwrite newer edits\n\nRefs: `frontend/src/components/KanbanBoard.tsx:50`, `frontend/src/components/KanbanBoard.tsx:58`, `frontend/src/components/KanbanBoard.tsx:70`, `frontend/src/components/AIChatSidebar.tsx:36`, `backend/routes/ai.py:63`\n\nThe board is saved after every state change. The AI sidebar sends a board snapshot, waits for a response, then replaces local state when the response returns. If the user keeps editing while the AI request is in flight, an older AI-based board can overwrite newer local changes. The AI path also causes duplicate writes because the backend saves the AI update, then the frontend autosave sends it again.\n\nRecommended fix:\n- Serialize or debounce writes.\n- Add board versioning or conflict detection.\n- Avoid re-saving AI updates that the backend already persisted.\n\n### Medium - AI-returned board updates are only shallow-checked before persistence\n\nRefs: `backend/routes/ai.py:62`, `backend/routes/ai.py:63`, `backend/models.py:18`\n\nThe AI route only checks that the result is a dict with `columns` and `cards`. It does not re-validate the full payload against the board model or confirm that `cardIds` match real cards.\n\nRecommended fix:\n- Run AI board output through `BoardModel` validation before saving.\n- Add integrity checks for card references.\n\n### Medium - Session tokens depend on a process-random secret by default\n\nRefs: `backend/auth.py:10`, `.env.example:1`\n\nIf `SESSION_SECRET` is not set, the app generates a random secret at startup. Tokens then become invalid after every restart, and multiple processes would disagree on token validity. The variable is also not documented in `.env.example`.\n\nRecommended fix:\n- Require `SESSION_SECRET` in configured environments.\n- Add it to `.env.example` and setup docs.\n\n### Medium - Structured AI response parsing is inconsistent for text-part responses\n\nRefs: `backend/openrouter.py:74`, `backend/openrouter.py:109`, `backend/openrouter.py:124`\n\n`extract_answer` handles list-based text parts, but `extract_json_object` returns the first dict content part directly. If the provider returns structured JSON inside a text part object, the parser can silently drop a valid AI update.\n\nRecommended fix:\n- Parse `text` fields in `extract_json_object` the same way `extract_answer` already does.\n- Add tests for content arrays that wrap JSON in text parts.\n\n### Medium - AI failures are returned as HTTP 200 responses\n\nRefs: `backend/routes/ai.py:16`, `backend/routes/ai.py:27`, `backend/routes/ai.py:43`, `backend/routes/ai.py:89`\n\nInvalid prompts and upstream AI failures are encoded as `success: false` in otherwise successful HTTP responses. That makes failures harder to detect and weakens API semantics.\n\nRecommended fix:\n- Return appropriate 4xx/5xx status codes for invalid input and upstream failures.\n- Keep the response body structured, but align transport-level status with outcome.\n\n### Medium - Drag and drop is mouse-only\n\nRefs: `frontend/src/components/KanbanBoard.tsx:7`, `frontend/src/components/KanbanBoard.tsx:27`, `frontend/src/components/KanbanCard.tsx:29`\n\nThe board only registers a `PointerSensor`. Keyboard users do not have an accessible drag path.\n\nRecommended fix:\n- Add `KeyboardSensor` support.\n- Verify focus and keyboard drag interactions in tests.\n\n### Medium - Test coverage misses several of the highest-risk cases\n\nRefs: `backend/test_api.py:120`, `backend/test_db.py:8`, `frontend/src/lib/api.test.ts:1`, `frontend/tests/kanban.spec.ts:16`, `frontend/tests/kanban.spec.ts:26`\n\nGaps I found:\n- No backend test proving `/api/ai/kanban` uses persisted board state instead of client-supplied state.\n- No backend test for the structured-response parser case above.\n- `backend/test_db.py` is a script, not a collected pytest test.\n- No frontend test for malformed board payloads or AI/save race behavior.\n- E2E tests mutate the live persisted board and do not reset state between tests.\n\nRecommended fix:\n- Add focused backend tests around AI state integrity and parser behavior.\n- Convert `backend/test_db.py` into pytest tests.\n- Add a deterministic board reset strategy for E2E.\n\n### Low - Auth state ownership in the frontend is confusing\n\nRefs: `frontend/src/app/page.tsx:16`, `frontend/src/lib/auth.ts:17`, `frontend/src/components/LoginScreen.tsx:29`\n\n`page.tsx` calls `saveAuth(true)`, but `saveAuth(true)` is intentionally a no-op. Token persistence actually happens in `LoginScreen`. The flow works, but the ownership is misleading and easy to break later.\n\nRecommended fix:\n- Collapse auth persistence into one clear API.\n\n### Low - Internal docs are stale\n\nRefs: `docs/code_review.md:1`, `backend/AGENTS.md:1`, `frontend/AGENTS.md:38`, `frontend/AGENTS.md:41`\n\nThe previous review doc described issues that are already fixed, `backend/AGENTS.md` is still a placeholder, and `frontend/AGENTS.md` still says there are no backend API calls and only local auth. The docs now lag the code enough to mislead future work.\n\nRecommended fix:\n- Refresh AGENTS/docs whenever the architecture changes.\n\n## Strengths\n\n- Backend responsibilities are separated cleanly across `backend/main.py`, `backend/routes/`, `backend/db.py`, `backend/auth.py`, and `backend/openrouter.py`.\n- SQL calls are parameterized rather than string-built. Refs: `backend/db.py:77`, `backend/db.py:163`, `backend/db.py:190`\n- The core board move logic is isolated and unit tested. Refs: `frontend/src/lib/kanban.ts:84`, `frontend/src/lib/kanban.test.ts:9`\n- Backend AI tests correctly mock the OpenRouter boundary. Refs: `backend/test_api.py:96`, `backend/test_api.py:138`\n- The static export plus FastAPI static serving approach is simple and aligned with the MVP. Refs: `frontend/next.config.ts:4`, `backend/main.py:30`\n\n## Recommended Priority Order\n\n1. Fix AI source-of-truth and AI board validation.\n2. Add durable SQLite storage in Docker.\n3. Make save behavior conflict-aware and remove duplicate AI/autosave writes.\n4. Add frontend runtime validation for board payloads.\n5. Refresh stale docs and missing setup notes.\n",
    "additions": 104,
    "deletions": 302,
    "status": "modified"
  }
]