diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..48bef08
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,31 @@
+# Copy to .env.local (app) / .env (Prisma CLI) and fill in real values.
+# Every value below is a placeholder — nothing here is a real credential.
+
+# ─── Supabase (auth) ─────────────────────────────────────────────────────────
+# Project Settings → API
+NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+
+# ─── Database ────────────────────────────────────────────────────────────────
+# Project Settings → Database → Connection string.
+# Use the session-mode (port 5432) URL — the pgbouncer/pooled URL (6543) makes
+# the Prisma pg adapter hang on DDL.
+DATABASE_URL="postgresql://postgres:your-password@db.your-project.supabase.co:5432/postgres"
+
+# Used by the Prisma CLI (generate/migrate/db push) when DATABASE_URL is pooled.
+DIRECT_URL="postgresql://postgres:your-password@db.your-project.supabase.co:5432/postgres"
+
+# ─── Optional ────────────────────────────────────────────────────────────────
+# Service-role key. Only needed to run the Playwright e2e suite: e2e/global-setup.ts
+# uses it to create/delete the test user. Never expose this to the browser.
+SUPABASE_SERVICE_ROLE_KEY=
+
+# Shared secret for the Vercel keepalive cron (/api/cron/keepalive). If unset,
+# the endpoint accepts unauthenticated requests.
+CRON_SECRET=
+
+# Canonical site URL used for metadata; defaults to the production URL.
+NEXT_PUBLIC_SITE_URL=http://localhost:3000
+
+# Throwaway local Postgres used by `npm run test:rls` (never point this at prod).
+RLS_TEST_DATABASE_URL=postgresql://localhost:5432/devtrack_rls
diff --git a/.gitignore b/.gitignore
index d42b29c..32bd0ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+# ...but the placeholder template must ship, the README tells you to copy it
+!.env.example
# vercel
.vercel
diff --git a/CLAUDE.md b/CLAUDE.md
index 9590a31..ae838c7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,9 +9,9 @@
**DevTrack** is a developer progress tracking dashboard. It allows developers to log daily coding activity, track DSA problems solved, manage side projects, and visualize progress over time.
-**Stage:** Foundation / Setup (no full features yet)
+**Stage:** Shipped — daily logs, DSA tracking + pattern analysis, projects/milestones, streaks, developer score, insights and recommendations are all implemented.
-**Live URL:** Not deployed yet
+**Live URL:** https://daily-dev-track.vercel.app
---
@@ -88,7 +88,7 @@
- **Prisma client** is a singleton via `lib/db/prisma.ts` — never instantiate `PrismaClient` elsewhere
- **Supabase client**: use `lib/auth/supabase.ts` (browser) and `lib/auth/supabase-server.ts` (server) — never create ad-hoc clients
- Always run `prisma generate` after any change to `schema.prisma`
-- **Prisma 7:** datasource URL lives in `prisma.config.ts` (not in `schema.prisma`) — this is a Prisma 7 breaking change
+- **Prisma 7:** datasource URL lives in `prisma.config.ts` (not in `schema.prisma`) — this is a Prisma 7 breaking change. The file must sit at the **repo root**: the CLI only looks for it in the current working directory, and from `prisma/prisma.config.ts` it is silently ignored (`Error: The datasource.url property is required in your Prisma config file`).
---
@@ -167,12 +167,26 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
DATABASE_URL=
```
-See `.env.example` for reference.
+Optional: `DIRECT_URL` (Prisma CLI when `DATABASE_URL` is pooled), `SUPABASE_SERVICE_ROLE_KEY` (Playwright global-setup), `CRON_SECRET` (guards `/api/cron/keepalive` — unset means the endpoint is open), `RLS_TEST_DATABASE_URL` (`npm run test:rls`).
+
+See `.env.example` for reference — it is committed via a `!.env.example` negation in `.gitignore`, since `.env*` is otherwise ignored.
---
## Session Notes
+**2026-08-15** — Clone-and-run verification pass; setup blockers and RLS proof:
+
+- **Fixed — Prisma config was never loaded:** `prisma/prisma.config.ts` moved to the repo root. Prisma 7.8 only looks in the cwd, so `prisma migrate dev` / `db push` failed with "The datasource.url property is required in your Prisma config file" for anyone following the README.
+- **Fixed — `.env.example` was not in the repo:** `.gitignore`'s `.env*` swallowed it, so the README's `cp .env.example .env` failed in a fresh clone. Added `!.env.example` and committed a placeholder-only template covering `DIRECT_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `CRON_SECRET`, `NEXT_PUBLIC_SITE_URL`, `RLS_TEST_DATABASE_URL`.
+- **Added — `prisma/rls/verify-rls.sql` + `npm run test:rls`:** applies the real `001_enable_rls.sql` to a throwaway local Postgres and asserts user A is denied every cross-user read, update, delete and forged insert on all 8 tables (plus anonymous access). Verified passing on Postgres 16; verified failing (exit 3) when the policy file is not applied, so it is not vacuous.
+- **Fixed — Realtime subscribed to a table that doesn't exist:** `hooks/useRealtimeLogs.ts` listened on `daily_logs` / `user_id` / `problems_solved`, but `schema.prisma` declares no `@@map`, so the real names are `"DailyLog"` / `"userId"` / `"problemsSolved"`. Needs a live check plus `ALTER PUBLICATION supabase_realtime ADD TABLE "DailyLog";`.
+- **Fixed — `dotenv` was undeclared** although `prisma.config.ts` imports it (it only worked as a transitive dep of `prisma`); added to devDependencies. `playwright.config.ts` `reuseExistingServer` is now `!process.env.CI`.
+- **README rewritten to match reality:** removed the Framer Motion claim (not a dependency, never used) and the "edge computing" claim (everything is the Node runtime); added the live URL, the unit-test/CI story, the RLS section, `npx playwright install`, and the e2e credential prerequisites.
+- **E2E status unchanged and still red without credentials:** `auth.setup.ts` fails at `page.waitForURL(/.*dashboard.*/)` and all 226 tests report "did not run". No `/api/health` route exists yet.
+
+**Next:** decide on a LICENSE, add a screenshot/demo GIF, and get the e2e suite green against a dedicated Supabase test project.
+
**2026-03-22** — Design system implementation verified and complete:
- CSS variables for light/dark themes configured in `globals.css` using oklch colors
diff --git a/README.md b/README.md
index 5acc67c..2400bab 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,30 @@
-
DevTrack
-
A Modern, High-Performance Developer Progress Tracking Dashboard
-
+
A developer progress tracker: daily logs, DSA practice, side projects — with per-user data isolation enforced in the database.
+
+
+ Live app →
+
+
-
+
---
-## ⚡ Overview
+## What this is
-**DevTrack** is an enterprise-grade web application built to help developers track their daily coding activities, monitor consistency, and visualize learning progress. Designed with premium aesthetics, it emphasizes data privacy, blazing-fast performance, and a sleek user experience via server-side rendering and edge computing.
+Log what you did today, what DSA problems you solved and which patterns they used, and what your side projects are up to. DevTrack turns that into streaks, pattern strengths/weaknesses, a developer score, and a handful of recommendations.
-## 🏗 System Architecture
+It is a single-developer product, so the interesting engineering is not the CRUD — it's that **one person's rows must never be reachable by another person**, and that this is enforced by Postgres row-level security rather than by remembering to write `where userId = …` in every query. See [Row-level security](#-row-level-security) for the policies and the test that proves they deny.
-The application adopts a modern, highly decoupled architecture using the **Serverless/Edge** compute model.
+## 🏗 Architecture
```mermaid
graph TD
@@ -44,7 +47,6 @@ graph TD
SupabaseAuth --> PostgreSQL
end
- %% Styles
classDef primary fill:#000,stroke:#333,stroke-width:2px,color:#fff;
classDef secondary fill:#fff,stroke:#333,stroke-width:2px,color:#000;
classDef db fill:#3ECF8E,stroke:#1A202C,stroke-width:2px,color:#000;
@@ -54,104 +56,148 @@ graph TD
class Database db;
```
+Everything runs on the Node.js runtime on Vercel (server components + route handlers); nothing runs on the edge runtime. Auth is Supabase; the app talks to the same Postgres through Prisma with the `pg` adapter.
+
## ✨ Features
-- 🏎️ **Next-Gen Performance**: Leveraging Next.js 16 and React 19 Server Components for instant load times and optimal SEO.
-- 🎨 **Premium Aesthetics**: Masterfully crafted UI utilizing **Tailwind CSS v4**, **Shadcn UI**, and Framer Motion for buttery-smooth micro-animations.
-- 🔐 **Robust Authentication**: Seamless and secure server-side auth integration powered by **Supabase**.
-- 🗄️ **Type-Safe Database Access**: Schema-first ORM integration with **Prisma** paired natively with a PostgreSQL instance.
-- 📊 **Interactive Data Visualizations**: Beautiful, responsive progress tracking charts powered by **Recharts**.
-- 🛡️ **Ironclad Type Safety**: End-to-end type safety from the database to the DOM using TypeScript and **Zod** schema validation.
-- 🚥 **Automated E2E Testing**: Comprehensive browser automation and regression testing using **Playwright**.
+- **Daily logs** — one entry per day per user (enforced by a unique index), with topics and notes.
+- **DSA tracking** — problems by difficulty, platform, and pattern; strongest/weakest pattern analysis.
+- **Projects & milestones** — progress recalculated from completed milestones, with an activity-log audit trail.
+- **Streaks** — current and longest, computed UTC-safe so a timezone change can't invent or break a streak.
+- **Developer score & recommendations** — sub-scores with caps and weights; a small rule engine that surfaces at most 3 suggestions.
+- **Per-user isolation** — RLS policies on all 8 user-owned tables (see below).
+- **Realtime** — daily-log inserts/updates/deletes sync across tabs via Supabase Realtime.
+- **Type safety** — TypeScript strict with no `any` in app code; Zod validation on every server action and route handler.
## 🛠 Tech Stack
-| Category | Technology | Description |
-| :--------------- | :-------------------- | :------------------------------------------ |
-| **Framework** | Next.js (App Router) | React framework for production |
-| **UI Library** | React 19 | Component-based UI rendering |
-| **Styling** | Tailwind CSS 4.0 | Utility-first CSS framework |
-| **Components** | Shadcn UI, Radix PR | Accessible, unstyled UI primitives |
-| **Database** | PostgreSQL | Relational database |
-| **ORM** | Prisma 7.5 | Next-generation Node.js and TypeScript ORM |
-| **Backend/Auth** | Supabase SSR | Open source Firebase alternative |
-| **Forms** | React Hook Form & Zod | Form state management and schema validation |
-| **Visuals** | Recharts, Lucide | Charts and SVG icon assets |
-| **Testing** | Playwright | End-to-end robust UI testing |
+| Category | Technology | Description |
+| :--------------- | :---------------------- | :------------------------------------------ |
+| **Framework** | Next.js 16 (App Router) | React framework for production |
+| **UI Library** | React 19 | Component-based UI rendering |
+| **Styling** | Tailwind CSS 4.0 | Utility-first CSS framework |
+| **Components** | Shadcn UI, Radix | Accessible, unstyled UI primitives |
+| **Database** | PostgreSQL (Supabase) | Relational database |
+| **ORM** | Prisma 7.5 | TypeScript ORM (`@prisma/adapter-pg`) |
+| **Backend/Auth** | Supabase SSR | Auth + Realtime |
+| **Forms** | React Hook Form & Zod | Form state management and schema validation |
+| **Visuals** | Recharts, Lucide | Charts and SVG icon assets |
+| **Testing** | Vitest + Playwright | Unit tests and end-to-end browser tests |
## 🚀 Getting Started
### Prerequisites
-- [Node.js](https://nodejs.org/) (v20+ recommended)
-- [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/)
-- A [Supabase](https://supabase.com) account & PostgreSQL connection URI
+- Node.js v20+ and npm
+- A [Supabase](https://supabase.com) project (auth) — its Postgres doubles as the database
+- For the RLS test only: a local `psql` client and a throwaway local Postgres
### Installation & Setup
-1. **Clone & Install Dependencies**
+1. **Clone & install** (`postinstall` runs `prisma generate`, which needs no env vars)
```bash
npm install
```
-2. **Environment Variables Configuration**
- Duplicate `.env.example` and rename to `.env`. Configure your actual database strings:
+2. **Environment variables**
```bash
- cp .env.example .env
+ cp .env.example .env.local # app reads .env.local
+ cp .env.example .env # Prisma CLI reads .env
```
-3. **Database Initialization**
- Generate the Prisma client and push your schema to the remote database:
+ Fill in `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, and `DATABASE_URL`. Use the **session-mode** connection string (port 5432) — the pooled pgbouncer URL (6543) makes the Prisma `pg` adapter hang on DDL.
+
+3. **Create the schema**
```bash
- npx prisma generate
- npx prisma migrate dev
+ npx prisma db push # or: npx prisma migrate dev
```
-4. **Launch Development Server**
+ `prisma.config.ts` (repo root) feeds the CLI its datasource URL from `DIRECT_URL` ?? `DATABASE_URL`.
+
+4. **Apply the row-level-security policies** — these are _not_ applied by `prisma db push`:
+
```bash
- npm run dev
+ psql "$DIRECT_URL" -f prisma/migrations/001_enable_rls.sql
```
- _The application will boot on [http://localhost:3000](http://localhost:3000)._
-## 🎨 Design Guidelines
+ (or paste the file into the Supabase SQL editor)
-Maintaining consistency is critical for our UI/UX:
+5. **Run it**
-- **Strictly Semantic Dark Mode**: DO NOT use `dark:` Tailwind prefixes inline. All colors are defined as variables in `globals.css` that transition based on the `.dark` class on the `` element.
-- **Anti-FOUC Strategies**: We utilize blocking inline scripts in `app/layout.tsx` to detect theme preference pre-paint.
-- **Charts Compatibility**: Always use explicit HEX-based CSS variables for SVG elements (Grid: `var(--chart-grid)`, Muted: `var(--chart-muted)`, Primary: `var(--primary)`).
-- **Accessibility FIRST**:
- - Provide unique `id` and `aria-label` tags on all interactive elements.
- - Rely on semantic HTML landmarks (`main`, `nav`, `section`).
- - Maintain a minimum **44x44px** touch target for mobile-exclusive controls.
+ ```bash
+ npm run dev # http://localhost:3000
+ ```
+
+Note: `npm run build` and `npm run dev` both need `DATABASE_URL` — page-data collection instantiates the Prisma client, and without it the build fails with `Error: DATABASE_URL is not defined`.
+
+## 🔐 Row-level security
+
+`prisma/migrations/001_enable_rls.sql` enables RLS and adds an owner policy on all 8 user-owned tables — `User`, `DailyLog`, `DSAProblem`, `Project`, `Milestone`, `ProjectActivityLog`, `Session`, and `SessionEvent` (gated through its parent `Session`, since it has no `userId` of its own). The rule is `auth.uid()::text = "userId"`.
+
+What this does and does not cover:
+
+- It **is** the defence for anything that reaches Postgres as the `authenticated`/`anon` role — Supabase Realtime and any direct PostgREST access.
+- It is **not** what isolates the app's own reads: Prisma connects as the table owner, which bypasses RLS. Server-side isolation comes from the `userId` filter in `lib/services/*`. RLS is the backstop for the paths that don't go through those services.
+
+Prove it rather than trusting it. Against a throwaway local Postgres:
+
+```bash
+createdb devtrack_rls
+DIRECT_URL=postgresql://localhost:5432/devtrack_rls npx prisma db push
+RLS_TEST_DATABASE_URL=postgresql://localhost:5432/devtrack_rls npm run test:rls
+```
+
+`prisma/rls/verify-rls.sql` stands up the pieces of Supabase the policies depend on (`auth.uid()`, the `authenticated`/`anon` roles), applies the real policy file, seeds two users, then asserts as user A that **every** cross-user read, update, delete, and forged insert is denied — and that an anonymous client sees nothing. Any leak raises and exits non-zero. The whole run is one transaction that rolls back, and the script refuses to run against a Supabase database.
## 🧪 Testing
-We employ **Playwright** for robust End-to-End (E2E) testing.
+**Unit tests** (Vitest, no database or network needed) — this is what CI runs on every push and PR, together with `type-check` and `lint`:
```bash
-# Run tests in headless mode
-npm run test
+npm run test:unit # 37 tests: date utils, streaks, scoring, recommendations
+```
-# Run tests with the Playwright UI
-npm run test:ui
+**RLS test** (local Postgres + psql) — see above:
-# View test report after run
-npm run test:report
+```bash
+npm run test:rls
```
-## 🌐 Deployment
+**End-to-end** (Playwright, 75 specs × 3 browsers = 226 tests). These are _not_ offline tests: they drive a real browser against a running dev server and sign in against a real Supabase project.
+
+```bash
+npx playwright install # once — downloads the browsers
+npm run test # headless
+npm run test:ui # Playwright UI
+npm run test:report # open the last HTML report
+```
-This application is highly optimized for deployment on [Vercel](https://vercel.com/new).
+Requirements, all of which must be in `.env.local` before the suite can pass:
+
+- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY` — a reachable Supabase project
+- `SUPABASE_SERVICE_ROLE_KEY` — `e2e/global-setup.ts` uses it to delete stale `devtrack.e2e.*@gmail.com` users and pre-create a confirmed test user
+- `DATABASE_URL` — the dev server Playwright starts needs it
+
+Without those, `e2e/auth.setup.ts` fails on `page.waitForURL(/.*dashboard.*/)` and every dependent test reports `did not run`. Known gaps are tracked in [`e2e/TEST_COVERAGE_GAP.md`](e2e/TEST_COVERAGE_GAP.md).
+
+## 🎨 Design Guidelines
+
+- **Semantic dark mode only**: no `dark:` Tailwind prefixes inline. Colors are CSS variables in `globals.css` that switch on the `.dark` class on ``.
+- **Anti-FOUC**: a blocking inline script in `app/layout.tsx` resolves the theme pre-paint.
+- **Charts**: use the explicit CSS variables for SVG (`var(--chart-grid)`, `var(--chart-muted)`, `var(--primary)`).
+- **Accessibility**: unique `id` + `aria-label` on interactive elements, semantic landmarks (`main`, `nav`, `section`), 44×44px minimum touch targets on mobile-only controls.
+
+Full rules live in [`CLAUDE.md`](CLAUDE.md).
+
+## 🌐 Deployment
-To ensure a successful build:
+Deployed on Vercel at [daily-dev-track.vercel.app](https://daily-dev-track.vercel.app). `vercel.json` registers a daily keepalive cron (`/api/cron/keepalive`, 09:00 UTC) so the free-tier Supabase project doesn't auto-pause; set `CRON_SECRET` or that endpoint accepts unauthenticated requests.
```bash
-npm run build
-# This runs \`npm run type-check && next build\`
+npm run build # runs `npm run type-check && next build`
```
-_Note: Ensure your Production Environment Variables on Vercel reflect your actual production Supabase / Postgres URIs._
+CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs type-check, lint, and the unit tests on every push and PR to `main`. It does not run the e2e suite (needs Supabase credentials) and does not gate the Vercel deployment.
diff --git a/hooks/useRealtimeLogs.ts b/hooks/useRealtimeLogs.ts
index 4043a7b..85ac5d9 100644
--- a/hooks/useRealtimeLogs.ts
+++ b/hooks/useRealtimeLogs.ts
@@ -12,10 +12,16 @@ interface UseRealtimeLogsResult {
}
/**
- * Subscribes to Supabase Realtime postgres_changes on the daily_logs table
+ * Subscribes to Supabase Realtime postgres_changes on the DailyLog table
* for the current user. Merges server-side initial logs with live inserts,
* updates, and deletes from other tabs/sessions.
*
+ * Table/column names are Prisma's — quoted PascalCase table (`DailyLog`) and
+ * camelCase columns (`userId`, `problemsSolved`), because schema.prisma
+ * declares no `@@map`/`@map`. Realtime also requires the table to be in the
+ * `supabase_realtime` publication:
+ * ALTER PUBLICATION supabase_realtime ADD TABLE "DailyLog";
+ *
* Design note: we optimistically update locally in DailyLogList on the same
* tab (via router.refresh), so Realtime primarily helps with multi-tab /
* multi-device sync. The channel is cleaned up on unmount to avoid leaking
@@ -40,21 +46,21 @@ export function useRealtimeLogs(
const supabase = createClient();
const channel = supabase
- .channel(`daily_logs:${userId}`)
+ .channel(`DailyLog:${userId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
- table: "daily_logs",
- filter: `user_id=eq.${userId}`,
+ table: "DailyLog",
+ filter: `userId=eq.${userId}`,
},
(payload) => {
if (payload.eventType === "INSERT") {
const newLog: SerializedDailyLog = {
id: payload.new.id as string,
date: payload.new.date as string,
- problemsSolved: (payload.new.problems_solved as number) ?? 0,
+ problemsSolved: (payload.new.problemsSolved as number) ?? 0,
topics: (payload.new.topics as string[]) ?? [],
notes: (payload.new.notes as string | null) ?? null,
};
@@ -67,7 +73,7 @@ export function useRealtimeLogs(
const updated: SerializedDailyLog = {
id: payload.new.id as string,
date: payload.new.date as string,
- problemsSolved: (payload.new.problems_solved as number) ?? 0,
+ problemsSolved: (payload.new.problemsSolved as number) ?? 0,
topics: (payload.new.topics as string[]) ?? [],
notes: (payload.new.notes as string | null) ?? null,
};
diff --git a/package-lock.json b/package-lock.json
index 911cbcf..be42e3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"prettier": "^3.8.1",
diff --git a/package.json b/package.json
index d4060b6..e422bc5 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"type-check": "tsc --noEmit",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
+ "test:rls": "psql \"${RLS_TEST_DATABASE_URL:?set RLS_TEST_DATABASE_URL to a throwaway local Postgres}\" -v ON_ERROR_STOP=1 -f prisma/rls/verify-rls.sql",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:parallel": "playwright test --workers=30",
@@ -50,6 +51,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"prettier": "^3.8.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index 12e6739..8465c5b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -66,7 +66,9 @@ export default defineConfig({
webServer: {
command: "npm run dev -- --port 3000",
url: "http://127.0.0.1:3000",
- reuseExistingServer: true,
+ // Reuse a dev server you already have running locally, but never on CI —
+ // there, an unexpected listener on :3000 would silently be tested instead.
+ reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
diff --git a/prisma/prisma.config.ts b/prisma.config.ts
similarity index 100%
rename from prisma/prisma.config.ts
rename to prisma.config.ts
diff --git a/prisma/rls/verify-rls.sql b/prisma/rls/verify-rls.sql
new file mode 100644
index 0000000..7832bea
--- /dev/null
+++ b/prisma/rls/verify-rls.sql
@@ -0,0 +1,222 @@
+-- ============================================================================
+-- RLS verification — proves the policies in prisma/migrations/001_enable_rls.sql
+-- actually DENY cross-user reads and writes.
+--
+-- Run it against a THROWAWAY LOCAL Postgres that has the DevTrack schema
+-- applied (see README → "Verifying row-level security"):
+--
+-- createdb devtrack_rls
+-- DIRECT_URL=postgresql://localhost:5432/devtrack_rls npx prisma db push
+-- RLS_TEST_DATABASE_URL=postgresql://localhost:5432/devtrack_rls npm run test:rls
+--
+-- Everything runs inside one transaction that is ROLLED BACK at the end, so the
+-- database is left untouched. The script refuses to run against a Supabase
+-- database (it would clobber the real `auth.uid()`).
+--
+-- Exit code 0 = every cross-user access was denied. Any leak raises an
+-- exception, which makes psql exit non-zero under `-v ON_ERROR_STOP=1`.
+-- ============================================================================
+
+\set ON_ERROR_STOP on
+\pset pager off
+
+BEGIN;
+
+-- ─── Safety: never run this against a real Supabase project ────────────────
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname IN ('supabase_admin', 'supabase_auth_admin'))
+ OR EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN
+ RAISE EXCEPTION 'Refusing to run: this looks like a Supabase database. Use a throwaway local Postgres.';
+ END IF;
+END $$;
+
+-- ─── Stand in for the bits of Supabase the policies depend on ──────────────
+-- Supabase exposes auth.uid() (the JWT `sub` claim) and connects API/Realtime
+-- clients as the `authenticated` / `anon` roles. Tables are owned by a
+-- different role, which is what makes RLS apply at all.
+CREATE SCHEMA IF NOT EXISTS auth;
+
+CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid
+LANGUAGE sql STABLE AS $$
+ SELECT NULLIF(NULLIF(current_setting('request.jwt.claims', true), '')::json ->> 'sub', '')::uuid
+$$;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
+ CREATE ROLE authenticated NOLOGIN;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
+ CREATE ROLE anon NOLOGIN;
+ END IF;
+END $$;
+
+GRANT USAGE ON SCHEMA public, auth TO authenticated, anon;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO authenticated, anon;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA auth TO authenticated, anon;
+
+-- ─── Apply the real policy file (not a copy of it) ─────────────────────────
+\ir ../migrations/001_enable_rls.sql
+
+-- ─── Every user-owned table must have RLS enabled AND at least one policy ──
+DO $$
+DECLARE
+ expected_table text;
+ missing text[] := '{}';
+BEGIN
+ FOREACH expected_table IN ARRAY ARRAY[
+ 'User', 'DailyLog', 'DSAProblem', 'Project',
+ 'Milestone', 'ProjectActivityLog', 'Session', 'SessionEvent'
+ ] LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public' AND c.relname = expected_table AND c.relrowsecurity
+ ) THEN
+ missing := missing || (expected_table || ' (RLS not enabled)');
+ ELSIF NOT EXISTS (
+ SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = expected_table
+ ) THEN
+ missing := missing || (expected_table || ' (no policy)');
+ END IF;
+ END LOOP;
+
+ IF array_length(missing, 1) IS NOT NULL THEN
+ RAISE EXCEPTION 'Tables without RLS coverage: %', array_to_string(missing, ', ');
+ END IF;
+ RAISE NOTICE 'RLS enabled + policy present on all 8 user-owned tables';
+END $$;
+
+-- ─── Seed two users, each owning one row per table ─────────────────────────
+-- Clear any fixtures left by a previous run (the transaction rolls back, so
+-- this only matters if someone ran the script with COMMIT).
+DELETE FROM "User" WHERE id IN (
+ '11111111-1111-1111-1111-111111111111',
+ '22222222-2222-2222-2222-222222222222'
+);
+
+INSERT INTO "User"(id, email) VALUES
+ ('11111111-1111-1111-1111-111111111111', 'alice@rls.test'),
+ ('22222222-2222-2222-2222-222222222222', 'bob@rls.test');
+
+INSERT INTO "DailyLog"(id, "userId", date, "problemsSolved", topics, "updatedAt") VALUES
+ ('rls-log-alice', '11111111-1111-1111-1111-111111111111', DATE '2026-01-01', 3, '{dp}', now()),
+ ('rls-log-bob', '22222222-2222-2222-2222-222222222222', DATE '2026-01-01', 5, '{graphs}', now());
+
+INSERT INTO "DSAProblem"(id, "userId", title, difficulty, pattern, platform, "solvedAt", "updatedAt") VALUES
+ ('rls-dsa-alice', '11111111-1111-1111-1111-111111111111', 'alice-private', 'EASY', 'dp', 'leetcode', now(), now()),
+ ('rls-dsa-bob', '22222222-2222-2222-2222-222222222222', 'bob-private', 'EASY', 'dp', 'leetcode', now(), now());
+
+INSERT INTO "Project"(id, "userId", name, status, progress, "techStack", "updatedAt") VALUES
+ ('rls-prj-alice', '11111111-1111-1111-1111-111111111111', 'alice-project', 'IN_PROGRESS', 0, '{}', now()),
+ ('rls-prj-bob', '22222222-2222-2222-2222-222222222222', 'bob-project', 'IN_PROGRESS', 0, '{}', now());
+
+INSERT INTO "Milestone"(id, "userId", "projectId", title, "order", "updatedAt") VALUES
+ ('rls-ms-alice', '11111111-1111-1111-1111-111111111111', 'rls-prj-alice', 'alice-milestone', 0, now()),
+ ('rls-ms-bob', '22222222-2222-2222-2222-222222222222', 'rls-prj-bob', 'bob-milestone', 0, now());
+
+INSERT INTO "ProjectActivityLog"(id, "userId", "projectId", action) VALUES
+ ('rls-pal-alice', '11111111-1111-1111-1111-111111111111', 'rls-prj-alice', 'PROJECT_CREATED'),
+ ('rls-pal-bob', '22222222-2222-2222-2222-222222222222', 'rls-prj-bob', 'PROJECT_CREATED');
+
+INSERT INTO "Session"(id, "userId", "startedAt", "updatedAt") VALUES
+ ('rls-ses-alice', '11111111-1111-1111-1111-111111111111', now(), now()),
+ ('rls-ses-bob', '22222222-2222-2222-2222-222222222222', now(), now());
+
+INSERT INTO "SessionEvent"(id, "sessionId", "activityType") VALUES
+ ('rls-sev-alice', 'rls-ses-alice', 'PROBLEM_SOLVED'),
+ ('rls-sev-bob', 'rls-ses-bob', 'PROBLEM_SOLVED');
+
+-- ─── Act as Alice, over the role a Supabase client actually uses ───────────
+SET LOCAL ROLE authenticated;
+SELECT set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111"}', true);
+
+DO $$
+DECLARE
+ alice constant text := '11111111-1111-1111-1111-111111111111';
+ visible bigint;
+ leaked bigint;
+BEGIN
+ -- Reads: Alice sees exactly her own row in every table, and Bob's rows are
+ -- invisible even when addressed by primary key.
+ SELECT count(*) INTO visible FROM "DailyLog";
+ IF visible <> 1 THEN RAISE EXCEPTION 'DailyLog: expected 1 visible row, got %', visible; END IF;
+
+ SELECT count(*) INTO leaked FROM "DailyLog" WHERE id = 'rls-log-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s DailyLog'; END IF;
+
+ SELECT count(*) INTO leaked FROM "DSAProblem" WHERE id = 'rls-dsa-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s DSAProblem'; END IF;
+
+ SELECT count(*) INTO leaked FROM "Project" WHERE id = 'rls-prj-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s Project'; END IF;
+
+ SELECT count(*) INTO leaked FROM "Milestone" WHERE id = 'rls-ms-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s Milestone'; END IF;
+
+ SELECT count(*) INTO leaked FROM "ProjectActivityLog" WHERE id = 'rls-pal-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s ProjectActivityLog'; END IF;
+
+ SELECT count(*) INTO leaked FROM "Session" WHERE id = 'rls-ses-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s Session'; END IF;
+
+ -- SessionEvent has no userId; it is gated through its parent Session.
+ SELECT count(*) INTO leaked FROM "SessionEvent" WHERE id = 'rls-sev-bob';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s SessionEvent'; END IF;
+
+ SELECT count(*) INTO leaked FROM "User" WHERE id = '22222222-2222-2222-2222-222222222222';
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can read bob''s User row'; END IF;
+
+ RAISE NOTICE 'reads denied across all 8 tables';
+
+ -- Writes: Alice cannot update or delete Bob's rows (they are simply not
+ -- visible to the UPDATE/DELETE), nor insert a row owned by Bob.
+ WITH updated AS (UPDATE "DailyLog" SET notes = 'pwned' WHERE id = 'rls-log-bob' RETURNING 1)
+ SELECT count(*) INTO leaked FROM updated;
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can update bob''s DailyLog'; END IF;
+
+ WITH deleted AS (DELETE FROM "DSAProblem" WHERE id = 'rls-dsa-bob' RETURNING 1)
+ SELECT count(*) INTO leaked FROM deleted;
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: alice can delete bob''s DSAProblem'; END IF;
+
+ BEGIN
+ INSERT INTO "DailyLog"(id, "userId", date, "problemsSolved", topics, "updatedAt")
+ VALUES ('rls-log-forged', '22222222-2222-2222-2222-222222222222', DATE '2026-02-02', 1, '{x}', now());
+ RAISE EXCEPTION 'LEAK: alice can insert a DailyLog owned by bob';
+ EXCEPTION
+ WHEN insufficient_privilege THEN
+ NULL; -- expected: WITH CHECK rejected the forged row
+ END;
+
+ RAISE NOTICE 'writes denied (update, delete, forged insert)';
+
+ -- Alice can still do everything with her own rows.
+ UPDATE "DailyLog" SET notes = 'mine' WHERE id = 'rls-log-alice';
+ IF NOT FOUND THEN RAISE EXCEPTION 'REGRESSION: alice cannot update her own DailyLog'; END IF;
+
+ INSERT INTO "DailyLog"(id, "userId", date, "problemsSolved", topics, "updatedAt")
+ VALUES ('rls-log-alice-2', alice, DATE '2026-01-02', 1, '{trees}', now());
+
+ RAISE NOTICE 'owner access still works';
+END $$;
+
+-- ─── An unauthenticated client (no JWT) must see nothing ───────────────────
+SELECT set_config('request.jwt.claims', NULL, true);
+SET LOCAL ROLE anon;
+
+DO $$
+DECLARE leaked bigint;
+BEGIN
+ SELECT count(*) INTO leaked FROM "DailyLog";
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: anonymous client can read % DailyLog rows', leaked; END IF;
+ SELECT count(*) INTO leaked FROM "User";
+ IF leaked <> 0 THEN RAISE EXCEPTION 'LEAK: anonymous client can read % User rows', leaked; END IF;
+ RAISE NOTICE 'anonymous access denied';
+END $$;
+
+RESET ROLE;
+
+SELECT 'RLS VERIFIED: cross-user reads and writes are denied on all 8 tables' AS result;
+
+ROLLBACK;