From 16baaf9707bc035754ef635e699fce543144ceda Mon Sep 17 00:00:00 2001 From: Shailesh Chaudhari Date: Sat, 15 Aug 2026 17:21:45 +0530 Subject: [PATCH] fix(setup): make a fresh clone actually runnable + prove RLS denies cross-user access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean `git clone` could not follow the README past step 2: - `.gitignore`'s `.env*` swallowed `.env.example`, so `cp .env.example .env` failed. Added a `!.env.example` negation and committed a placeholder-only template that also documents DIRECT_URL, SUPABASE_SERVICE_ROLE_KEY, CRON_SECRET and NEXT_PUBLIC_SITE_URL. - `prisma/prisma.config.ts` was never loaded by the Prisma CLI (7.8 only looks in the cwd), so `prisma migrate dev` / `db push` died with "The datasource.url property is required in your Prisma config file" even with a correct .env. Moved it to the repo root; `db push` now works. - `dotenv` is imported by prisma.config.ts but was only present as a transitive dependency of prisma; declared it in devDependencies. RLS was version-controlled but nothing proved it denied anything. Added `prisma/rls/verify-rls.sql` + `npm run test:rls`: it stands up the Supabase pieces the policies depend on (auth.uid(), the authenticated/anon roles), applies the real 001_enable_rls.sql, seeds two users, then asserts that user A is denied every cross-user read, update, delete and forged insert across all 8 tables, and that an anonymous client sees nothing. One transaction, rolled back; refuses to run against a Supabase database. Verified green on Postgres 16 and verified red (exit 3) when the policy file is not applied. Also: - hooks/useRealtimeLogs.ts subscribed to `daily_logs` / `user_id` / `problems_solved`, but schema.prisma declares no @@map, so the real names are "DailyLog" / "userId" / "problemsSolved" — the subscription could never fire. Corrected, with a note that the table must be added to the supabase_realtime publication. - playwright.config.ts: reuseExistingServer is now !process.env.CI, so CI can't silently test whatever else happens to be listening on :3000. - README: dropped the Framer Motion claim (not a dependency, never imported) 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 credentials the e2e suite actually needs. --- .env.example | 31 +++ .gitignore | 2 + CLAUDE.md | 22 +- README.md | 174 +++++++++------ hooks/useRealtimeLogs.ts | 18 +- package-lock.json | 1 + package.json | 2 + playwright.config.ts | 4 +- prisma/prisma.config.ts => prisma.config.ts | 0 prisma/rls/verify-rls.sql | 222 ++++++++++++++++++++ 10 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 .env.example rename prisma/prisma.config.ts => prisma.config.ts (100%) create mode 100644 prisma/rls/verify-rls.sql 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 @@
- Next.js

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 → +

+

Next.js React TypeScript Prisma - Supabase + Supabase Tailwind CSS

--- -## ⚡ 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;